diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 8187eee52..cc4b9a456 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -40,7 +40,7 @@ jobs:
- name: Get pip cache dir
id: pip-cache
run: |
- echo "::set-output name=dir::$(pip cache dir)"
+ echo "dir=$(pip cache dir)" >> $GITHUB_OUTPUT
- name: Cache
uses: actions/cache@v3
@@ -112,7 +112,7 @@ jobs:
- name: Get pip cache dir
id: pip-cache
run: |
- echo "::set-output name=dir::$(pip cache dir)"
+ echo "dir=$(pip cache dir)" >> $GITHUB_OUTPUT
- name: Cache
uses: actions/cache@v3
@@ -165,7 +165,7 @@ jobs:
- name: Get pip cache dir
id: pip-cache
run: |
- echo "::set-output name=dir::$(pip cache dir)"
+ echo "dir=$(pip cache dir)" >> $GITHUB_OUTPUT
- name: Cache
uses: actions/cache@v3
@@ -240,7 +240,7 @@ jobs:
- name: Get pip cache dir
id: pip-cache
run: |
- echo "::set-output name=dir::$(pip cache dir)"
+ echo "dir=$(pip cache dir)" >> $GITHUB_OUTPUT
- name: Cache
uses: actions/cache@v3
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index b5264e4de..124892d78 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -16,7 +16,7 @@ repos:
hooks:
- id: doc8
- repo: https://github.com/asottile/pyupgrade
- rev: v3.3.1
+ rev: v3.4.0
hooks:
- id: pyupgrade
args: [--py38-plus]
@@ -39,14 +39,14 @@ repos:
- id: rst-backticks
- id: rst-directive-colons
- repo: https://github.com/pre-commit/mirrors-prettier
- rev: v3.0.0-alpha.6
+ rev: v3.0.0-alpha.9-for-vscode
hooks:
- id: prettier
types_or: [javascript, css]
args:
- --trailing-comma=es5
- repo: https://github.com/pre-commit/mirrors-eslint
- rev: v8.36.0
+ rev: v8.40.0
hooks:
- id: eslint
files: \.js?$
@@ -54,16 +54,16 @@ repos:
args:
- --fix
- repo: https://github.com/psf/black
- rev: 23.1.0
+ rev: 23.3.0
hooks:
- id: black
language_version: python3
entry: black --target-version=py38
- repo: https://github.com/tox-dev/pyproject-fmt
- rev: 0.9.2
+ rev: 0.11.2
hooks:
- id: pyproject-fmt
- repo: https://github.com/abravalheri/validate-pyproject
- rev: v0.12.1
+ rev: v0.12.2
hooks:
- id: validate-pyproject
diff --git a/README.rst b/README.rst
index e3a40b8a1..b10a9ad91 100644
--- a/README.rst
+++ b/README.rst
@@ -16,7 +16,7 @@ Django Debug Toolbar |latest-version|
:target: https://github.com/jazzband/django-debug-toolbar/actions
:alt: Build Status
-.. |coverage| image:: https://img.shields.io/badge/Coverage-93%25-green
+.. |coverage| image:: https://img.shields.io/badge/Coverage-94%25-green
:target: https://github.com/jazzband/django-debug-toolbar/actions/workflows/test.yml?query=branch%3Amain
:alt: Test coverage status
@@ -44,7 +44,7 @@ Here's a screenshot of the toolbar in action:
In addition to the built-in panels, a number of third-party panels are
contributed by the community.
-The current stable version of the Debug Toolbar is 4.0.0. It works on
+The current stable version of the Debug Toolbar is 4.1.0. It works on
Django ≥ 3.2.4.
Documentation, including installation and configuration instructions, is
diff --git a/debug_toolbar/__init__.py b/debug_toolbar/__init__.py
index 109d7d4d7..1a9cf7c93 100644
--- a/debug_toolbar/__init__.py
+++ b/debug_toolbar/__init__.py
@@ -4,7 +4,7 @@
# Do not use pkg_resources to find the version but set it here directly!
# see issue #1446
-VERSION = "4.0.0"
+VERSION = "4.1.0"
# Code that discovers files or modules in INSTALLED_APPS imports this module.
urls = "debug_toolbar.urls", APP_NAME
diff --git a/debug_toolbar/management/commands/debugsqlshell.py b/debug_toolbar/management/commands/debugsqlshell.py
index 93514d121..b80577232 100644
--- a/debug_toolbar/management/commands/debugsqlshell.py
+++ b/debug_toolbar/management/commands/debugsqlshell.py
@@ -1,4 +1,4 @@
-from time import time
+from time import perf_counter
import sqlparse
from django.core.management.commands.shell import Command
@@ -19,12 +19,12 @@
class PrintQueryWrapper(base_module.CursorDebugWrapper):
def execute(self, sql, params=()):
- start_time = time()
+ start_time = perf_counter()
try:
return self.cursor.execute(sql, params)
finally:
raw_sql = self.db.ops.last_executed_query(self.cursor, sql, params)
- end_time = time()
+ end_time = perf_counter()
duration = (end_time - start_time) * 1000
formatted_sql = sqlparse.format(raw_sql, reindent=True)
print(f"{formatted_sql} [{duration:.2f}ms]")
diff --git a/debug_toolbar/panels/cache.py b/debug_toolbar/panels/cache.py
index f5ceea513..31ce70988 100644
--- a/debug_toolbar/panels/cache.py
+++ b/debug_toolbar/panels/cache.py
@@ -1,5 +1,5 @@
import functools
-import time
+from time import perf_counter
from asgiref.local import Local
from django.conf import settings
@@ -30,6 +30,27 @@
]
+def _monkey_patch_method(cache, name):
+ original_method = getattr(cache, name)
+
+ @functools.wraps(original_method)
+ def wrapper(*args, **kwargs):
+ panel = cache._djdt_panel
+ if panel is None:
+ return original_method(*args, **kwargs)
+ else:
+ return panel._record_call(cache, name, original_method, args, kwargs)
+
+ setattr(cache, name, wrapper)
+
+
+def _monkey_patch_cache(cache):
+ if not hasattr(cache, "_djdt_patched"):
+ for name in WRAPPED_CACHE_METHODS:
+ _monkey_patch_method(cache, name)
+ cache._djdt_patched = True
+
+
class CachePanel(Panel):
"""
Panel that displays the cache statistics.
@@ -72,7 +93,8 @@ def wrapper(self, alias):
cache = original_method(self, alias)
panel = cls.current_instance()
if panel is not None:
- panel._monkey_patch_cache(cache)
+ _monkey_patch_cache(cache)
+ cache._djdt_panel = panel
return cache
CacheHandler.create_connection = wrapper
@@ -120,14 +142,17 @@ def _store_call_info(
def _record_call(self, cache, name, original_method, args, kwargs):
# Some cache backends implement certain cache methods in terms of other cache
# methods (e.g. get_or_set() in terms of get() and add()). In order to only
- # record the calls made directly by the user code, set the _djdt_recording flag
- # here to cause the monkey patched cache methods to skip recording additional
- # calls made during the course of this call.
- cache._djdt_recording = True
- t = time.time()
- value = original_method(*args, **kwargs)
- t = time.time() - t
- cache._djdt_recording = False
+ # record the calls made directly by the user code, set the cache's _djdt_panel
+ # attribute to None before invoking the original method, which will cause the
+ # monkey-patched cache methods to skip recording additional calls made during
+ # the course of this call, and then reset it back afterward.
+ cache._djdt_panel = None
+ try:
+ start_time = perf_counter()
+ value = original_method(*args, **kwargs)
+ t = perf_counter() - start_time
+ finally:
+ cache._djdt_panel = self
self._store_call_info(
name=name,
@@ -141,40 +166,6 @@ def _record_call(self, cache, name, original_method, args, kwargs):
)
return value
- def _monkey_patch_method(self, cache, name):
- original_method = getattr(cache, name)
-
- @functools.wraps(original_method)
- def wrapper(*args, **kwargs):
- # If this call is being made as part of the implementation of another cache
- # method, don't record it.
- if cache._djdt_recording:
- return original_method(*args, **kwargs)
- else:
- return self._record_call(cache, name, original_method, args, kwargs)
-
- wrapper._djdt_wrapped = original_method
- setattr(cache, name, wrapper)
-
- def _monkey_patch_cache(self, cache):
- if not hasattr(cache, "_djdt_patched"):
- for name in WRAPPED_CACHE_METHODS:
- self._monkey_patch_method(cache, name)
- cache._djdt_patched = True
- cache._djdt_recording = False
-
- @staticmethod
- def _unmonkey_patch_cache(cache):
- if hasattr(cache, "_djdt_patched"):
- for name in WRAPPED_CACHE_METHODS:
- original_method = getattr(cache, name)._djdt_wrapped
- if original_method.__func__ == getattr(cache.__class__, name):
- delattr(cache, name)
- else:
- setattr(cache, name, original_method)
- del cache._djdt_patched
- del cache._djdt_recording
-
# Implement the Panel API
nav_title = _("Cache")
@@ -204,7 +195,8 @@ def enable_instrumentation(self):
# the .ready() method will ensure that any new cache connections that get opened
# during this request will also be monkey patched.
for cache in caches.all(initialized_only=True):
- self._monkey_patch_cache(cache)
+ _monkey_patch_cache(cache)
+ cache._djdt_panel = self
# Mark this panel instance as the current one for the active thread/async task
# context. This will be used by the CacheHander.create_connection() monkey
# patch.
@@ -214,7 +206,7 @@ def disable_instrumentation(self):
if hasattr(self._context_locals, "current_instance"):
del self._context_locals.current_instance
for cache in caches.all(initialized_only=True):
- self._unmonkey_patch_cache(cache)
+ cache._djdt_panel = None
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 90e2ba812..c8576e16f 100644
--- a/debug_toolbar/panels/sql/panel.py
+++ b/debug_toolbar/panels/sql/panel.py
@@ -10,7 +10,7 @@
from debug_toolbar.panels import Panel
from debug_toolbar.panels.sql import views
from debug_toolbar.panels.sql.forms import SQLSelectForm
-from debug_toolbar.panels.sql.tracking import unwrap_cursor, wrap_cursor
+from debug_toolbar.panels.sql.tracking import wrap_cursor
from debug_toolbar.panels.sql.utils import contrasting_color_generator, reformat_sql
from debug_toolbar.utils import render_stacktrace
@@ -190,11 +190,12 @@ def get_urls(cls):
def enable_instrumentation(self):
# This is thread-safe because database connections are thread-local.
for connection in connections.all():
- wrap_cursor(connection, self)
+ wrap_cursor(connection)
+ connection._djdt_logger = self
def disable_instrumentation(self):
for connection in connections.all():
- unwrap_cursor(connection)
+ connection._djdt_logger = None
def generate_stats(self, request, response):
colors = contrasting_color_generator()
diff --git a/debug_toolbar/panels/sql/tracking.py b/debug_toolbar/panels/sql/tracking.py
index 565d9244b..a85ac51ad 100644
--- a/debug_toolbar/panels/sql/tracking.py
+++ b/debug_toolbar/panels/sql/tracking.py
@@ -1,8 +1,10 @@
import contextvars
import datetime
import json
-from time import time
+from time import perf_counter
+import django.test.testcases
+from django.db.backends.utils import CursorWrapper
from django.utils.encoding import force_str
from debug_toolbar import settings as dt_settings
@@ -31,10 +33,15 @@ class SQLQueryTriggered(Exception):
"""Thrown when template panel triggers a query"""
-def wrap_cursor(connection, panel):
+def wrap_cursor(connection):
+ # If running a Django SimpleTestCase, which isn't allowed to access the database,
+ # don't perform any monkey patching.
+ if isinstance(connection.cursor, django.test.testcases._DatabaseFailure):
+ return
if not hasattr(connection, "_djdt_cursor"):
connection._djdt_cursor = connection.cursor
connection._djdt_chunked_cursor = connection.chunked_cursor
+ connection._djdt_logger = None
def cursor(*args, **kwargs):
# Per the DB API cursor() does not accept any arguments. There's
@@ -43,78 +50,55 @@ def cursor(*args, **kwargs):
# See:
# https://github.com/jazzband/django-debug-toolbar/pull/615
# https://github.com/jazzband/django-debug-toolbar/pull/896
+ logger = connection._djdt_logger
+ cursor = connection._djdt_cursor(*args, **kwargs)
+ if logger is None:
+ return cursor
if allow_sql.get():
wrapper = NormalCursorWrapper
else:
wrapper = ExceptionCursorWrapper
- return wrapper(connection._djdt_cursor(*args, **kwargs), connection, panel)
+ return wrapper(cursor.cursor, connection, logger)
def chunked_cursor(*args, **kwargs):
# prevent double wrapping
# solves https://github.com/jazzband/django-debug-toolbar/issues/1239
+ logger = connection._djdt_logger
cursor = connection._djdt_chunked_cursor(*args, **kwargs)
- if not isinstance(cursor, BaseCursorWrapper):
+ if logger is not None and not isinstance(cursor, DjDTCursorWrapper):
if allow_sql.get():
wrapper = NormalCursorWrapper
else:
wrapper = ExceptionCursorWrapper
- return wrapper(cursor, connection, panel)
+ return wrapper(cursor.cursor, connection, logger)
return cursor
connection.cursor = cursor
connection.chunked_cursor = chunked_cursor
- return cursor
-
-
-def unwrap_cursor(connection):
- if hasattr(connection, "_djdt_cursor"):
- # Sometimes the cursor()/chunked_cursor() methods of the DatabaseWrapper
- # instance are already monkey patched before wrap_cursor() is called. (In
- # particular, Django's SimpleTestCase monkey patches those methods for any
- # disallowed databases to raise an exception if they are accessed.) Thus only
- # delete our monkey patch if the method we saved is the same as the class
- # method. Otherwise, restore the prior monkey patch from our saved method.
- if connection._djdt_cursor == connection.__class__.cursor:
- del connection.cursor
- else:
- connection.cursor = connection._djdt_cursor
- del connection._djdt_cursor
- if connection._djdt_chunked_cursor == connection.__class__.chunked_cursor:
- del connection.chunked_cursor
- else:
- connection.chunked_cursor = connection._djdt_chunked_cursor
- del connection._djdt_chunked_cursor
-class BaseCursorWrapper:
- pass
+class DjDTCursorWrapper(CursorWrapper):
+ def __init__(self, cursor, db, logger):
+ super().__init__(cursor, db)
+ # logger must implement a ``record`` method
+ self.logger = logger
-class ExceptionCursorWrapper(BaseCursorWrapper):
+class ExceptionCursorWrapper(DjDTCursorWrapper):
"""
Wraps a cursor and raises an exception on any operation.
Used in Templates panel.
"""
- def __init__(self, cursor, db, logger):
- pass
-
def __getattr__(self, attr):
raise SQLQueryTriggered()
-class NormalCursorWrapper(BaseCursorWrapper):
+class NormalCursorWrapper(DjDTCursorWrapper):
"""
Wraps a cursor and logs queries.
"""
- def __init__(self, cursor, db, logger):
- self.cursor = cursor
- # Instance of a BaseDatabaseWrapper subclass
- self.db = db
- # logger must implement a ``record`` method
- self.logger = logger
-
def _quote_expr(self, element):
if isinstance(element, str):
return "'%s'" % element.replace("'", "''")
@@ -154,6 +138,21 @@ def _decode(self, param):
except UnicodeDecodeError:
return "(encoded string)"
+ def _last_executed_query(self, sql, params):
+ """Get the last executed query from the connection."""
+ # Django's psycopg3 backend creates a new cursor in its implementation of the
+ # .last_executed_query() method. To avoid wrapping that cursor, temporarily set
+ # the DatabaseWrapper's ._djdt_logger attribute to None. This will cause the
+ # monkey-patched .cursor() and .chunked_cursor() methods to skip the wrapping
+ # process during the .last_executed_query() call.
+ self.db._djdt_logger = None
+ try:
+ return self.db.ops.last_executed_query(
+ self.cursor, sql, self._quote_params(params)
+ )
+ finally:
+ self.db._djdt_logger = self.logger
+
def _record(self, method, sql, params):
alias = self.db.alias
vendor = self.db.vendor
@@ -163,11 +162,11 @@ def _record(self, method, sql, params):
conn = self.db.connection
initial_conn_status = conn.info.transaction_status
- start_time = time()
+ start_time = perf_counter()
try:
return method(sql, params)
finally:
- stop_time = time()
+ stop_time = perf_counter()
duration = (stop_time - start_time) * 1000
_params = ""
try:
@@ -186,9 +185,7 @@ def _record(self, method, sql, params):
params = {
"vendor": vendor,
"alias": alias,
- "sql": self.db.ops.last_executed_query(
- self.cursor, sql, self._quote_params(params)
- ),
+ "sql": self._last_executed_query(sql, params),
"duration": duration,
"raw_sql": sql,
"params": _params,
@@ -196,7 +193,9 @@ def _record(self, method, sql, params):
"stacktrace": get_stack_trace(skip=2),
"start_time": start_time,
"stop_time": stop_time,
- "is_slow": duration > dt_settings.get_config()["SQL_WARNING_THRESHOLD"],
+ "is_slow": (
+ duration > dt_settings.get_config()["SQL_WARNING_THRESHOLD"]
+ ),
"is_select": sql.lower().strip().startswith("select"),
"template_info": template_info,
}
@@ -241,22 +240,10 @@ def _record(self, method, sql, params):
self.logger.record(**params)
def callproc(self, procname, params=None):
- return self._record(self.cursor.callproc, procname, params)
+ return self._record(super().callproc, procname, params)
def execute(self, sql, params=None):
- return self._record(self.cursor.execute, sql, params)
+ return self._record(super().execute, sql, params)
def executemany(self, sql, param_list):
- return self._record(self.cursor.executemany, sql, param_list)
-
- def __getattr__(self, attr):
- return getattr(self.cursor, attr)
-
- def __iter__(self):
- return iter(self.cursor)
-
- def __enter__(self):
- return self
-
- def __exit__(self, type, value, traceback):
- self.close()
+ return self._record(super().executemany, sql, param_list)
diff --git a/debug_toolbar/panels/sql/utils.py b/debug_toolbar/panels/sql/utils.py
index 0fbba3e90..efd7c1637 100644
--- a/debug_toolbar/panels/sql/utils.py
+++ b/debug_toolbar/panels/sql/utils.py
@@ -1,70 +1,128 @@
-import re
from functools import lru_cache
+from html import escape
import sqlparse
-from django.utils.html import escape
+from django.dispatch import receiver
+from django.test.signals import setting_changed
from sqlparse import tokens as T
from debug_toolbar import settings as dt_settings
-class BoldKeywordFilter:
- """sqlparse filter to bold SQL keywords"""
+class ElideSelectListsFilter:
+ """sqlparse filter to elide the select list from top-level SELECT ... FROM clauses,
+ if present"""
def process(self, stream):
- """Process the token stream"""
+ allow_elision = True
+ for token_type, value in stream:
+ yield token_type, value
+ if token_type in T.Keyword:
+ keyword = value.upper()
+ if allow_elision and keyword == "SELECT":
+ yield from self.elide_until_from(stream)
+ allow_elision = keyword in ["EXCEPT", "INTERSECT", "UNION"]
+
+ @staticmethod
+ def elide_until_from(stream):
+ has_dot = False
+ saved_tokens = []
for token_type, value in stream:
- is_keyword = token_type in T.Keyword
- if is_keyword:
- yield T.Text, ""
- yield token_type, escape(value)
- if is_keyword:
- yield T.Text, ""
+ if token_type in T.Keyword and value.upper() == "FROM":
+ # Do not elide a select lists that do not contain dots (used to separate
+ # table names from column names) in order to preserve
+ # SELECT COUNT(*) AS `__count` FROM ...
+ # and
+ # SELECT (1) AS `a` FROM ...
+ # queries.
+ if not has_dot:
+ yield from saved_tokens
+ else:
+ # U+2022: Unicode character 'BULLET'
+ yield T.Other, " \u2022\u2022\u2022 "
+ yield token_type, value
+ break
+ if not has_dot:
+ if token_type in T.Punctuation and value == ".":
+ has_dot = True
+ else:
+ saved_tokens.append((token_type, value))
+
+
+class BoldKeywordFilter:
+ """sqlparse filter to bold SQL keywords"""
+
+ def process(self, stmt):
+ idx = 0
+ while idx < len(stmt.tokens):
+ token = stmt[idx]
+ if token.is_keyword:
+ stmt.insert_before(idx, sqlparse.sql.Token(T.Other, ""))
+ stmt.insert_after(
+ idx + 1,
+ sqlparse.sql.Token(T.Other, ""),
+ skip_ws=False,
+ )
+ idx += 2
+ elif token.is_group:
+ self.process(token)
+ idx += 1
+
+
+def escaped_value(token):
+ # Don't escape T.Whitespace tokens because AlignedIndentFilter inserts its tokens as
+ # T.Whitesapce, and in our case those tokens are actually HTML.
+ if token.ttype in (T.Other, T.Whitespace):
+ return token.value
+ return escape(token.value, quote=False)
+
+
+class EscapedStringSerializer:
+ """sqlparse post-processor to convert a Statement into a string escaped for
+ inclusion in HTML ."""
+
+ @staticmethod
+ def process(stmt):
+ return "".join(escaped_value(token) for token in stmt.flatten())
def reformat_sql(sql, with_toggle=False):
- formatted = parse_sql(sql, aligned_indent=True)
+ formatted = parse_sql(sql)
if not with_toggle:
return formatted
- simple = simplify(parse_sql(sql, aligned_indent=False))
- uncollapsed = f'{simple}'
+ simplified = parse_sql(sql, simplify=True)
+ uncollapsed = f'{simplified}'
collapsed = f'{formatted}'
return collapsed + uncollapsed
-def parse_sql(sql, aligned_indent=False):
- return _parse_sql(
- sql,
- dt_settings.get_config()["PRETTIFY_SQL"],
- aligned_indent,
- )
-
-
@lru_cache(maxsize=128)
-def _parse_sql(sql, pretty, aligned_indent):
- stack = get_filter_stack(pretty, aligned_indent)
+def parse_sql(sql, *, simplify=False):
+ stack = get_filter_stack(simplify=simplify)
return "".join(stack.run(sql))
@lru_cache(maxsize=None)
-def get_filter_stack(prettify, aligned_indent):
+def get_filter_stack(*, simplify):
stack = sqlparse.engine.FilterStack()
- if prettify:
- stack.enable_grouping()
- if aligned_indent:
+ if simplify:
+ stack.preprocess.append(ElideSelectListsFilter())
+ else:
+ if dt_settings.get_config()["PRETTIFY_SQL"]:
+ stack.enable_grouping()
stack.stmtprocess.append(
sqlparse.filters.AlignedIndentFilter(char=" ", n="
")
)
- stack.preprocess.append(BoldKeywordFilter()) # add our custom filter
- stack.postprocess.append(sqlparse.filters.SerializerUnicode()) # tokens -> strings
+ stack.stmtprocess.append(BoldKeywordFilter())
+ stack.postprocess.append(EscapedStringSerializer()) # Statement -> str
return stack
-simplify_re = re.compile(r"SELECT (...........*?) FROM")
-
-
-def simplify(sql):
- return simplify_re.sub(r"SELECT ••• FROM", sql)
+@receiver(setting_changed)
+def clear_caches(*, setting, **kwargs):
+ if setting == "DEBUG_TOOLBAR_CONFIG":
+ parse_sql.cache_clear()
+ get_filter_stack.cache_clear()
def contrasting_color_generator():
diff --git a/debug_toolbar/panels/staticfiles.py b/debug_toolbar/panels/staticfiles.py
index c02194071..bee336249 100644
--- a/debug_toolbar/panels/staticfiles.py
+++ b/debug_toolbar/panels/staticfiles.py
@@ -3,7 +3,6 @@
from django.conf import settings
from django.contrib.staticfiles import finders, storage
from django.core.checks import Warning
-from django.core.files.storage import get_storage_class
from django.utils.functional import LazyObject
from django.utils.translation import gettext_lazy as _, ngettext
@@ -53,7 +52,17 @@ class DebugConfiguredStorage(LazyObject):
"""
def _setup(self):
- configured_storage_cls = get_storage_class(settings.STATICFILES_STORAGE)
+ try:
+ # From Django 4.2 use django.core.files.storage.storages in favor
+ # of the deprecated django.core.files.storage.get_storage_class
+ from django.core.files.storage import storages
+
+ configured_storage_cls = storages["staticfiles"].__class__
+ except ImportError:
+ # Backwards compatibility for Django versions prior to 4.2
+ from django.core.files.storage import get_storage_class
+
+ configured_storage_cls = get_storage_class(settings.STATICFILES_STORAGE)
class DebugStaticFilesStorage(configured_storage_cls):
def __init__(self, collector, *args, **kwargs):
diff --git a/debug_toolbar/panels/timer.py b/debug_toolbar/panels/timer.py
index 801c9c6fd..554798e7d 100644
--- a/debug_toolbar/panels/timer.py
+++ b/debug_toolbar/panels/timer.py
@@ -1,4 +1,4 @@
-import time
+from time import perf_counter
from django.template.loader import render_to_string
from django.templatetags.static import static
@@ -59,7 +59,7 @@ def scripts(self):
return scripts
def process_request(self, request):
- self._start_time = time.time()
+ self._start_time = perf_counter()
if self.has_content:
self._start_rusage = resource.getrusage(resource.RUSAGE_SELF)
return super().process_request(request)
@@ -67,7 +67,7 @@ def process_request(self, request):
def generate_stats(self, request, response):
stats = {}
if hasattr(self, "_start_time"):
- stats["total_time"] = (time.time() - self._start_time) * 1000
+ stats["total_time"] = (perf_counter() - self._start_time) * 1000
if hasattr(self, "_start_rusage"):
self._end_rusage = resource.getrusage(resource.RUSAGE_SELF)
stats["utime"] = 1000 * self._elapsed_ru("ru_utime")
diff --git a/debug_toolbar/static/debug_toolbar/css/toolbar.css b/debug_toolbar/static/debug_toolbar/css/toolbar.css
index 4ad2a6df9..4adb0abb5 100644
--- a/debug_toolbar/static/debug_toolbar/css/toolbar.css
+++ b/debug_toolbar/static/debug_toolbar/css/toolbar.css
@@ -1,3 +1,17 @@
+/* Variable definitions */
+:root {
+ /* Font families are the same as in Django admin/css/base.css */
+ --djdt-font-family-primary: -apple-system, BlinkMacSystemFont, "Segoe UI",
+ system-ui, Roboto, "Helvetica Neue", Arial, sans-serif,
+ "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol",
+ "Noto Color Emoji";
+ --djdt-font-family-monospace: ui-monospace, Menlo, Monaco, "Cascadia Mono",
+ "Segoe UI Mono", "Roboto Mono", "Oxygen Mono", "Ubuntu Monospace",
+ "Source Code Pro", "Fira Mono", "Droid Sans Mono", "Courier New",
+ monospace, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol",
+ "Noto Color Emoji";
+}
+
/* Debug Toolbar CSS Reset, adapted from Eric Meyer's CSS Reset */
#djDebug {
color: #000;
@@ -77,9 +91,7 @@
color: #000;
vertical-align: baseline;
background-color: transparent;
- font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui,
- Roboto, "Helvetica Neue", Arial, sans-serif, "Apple Color Emoji",
- "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
+ font-family: var(--djdt-font-family-primary);
text-align: left;
text-shadow: none;
white-space: normal;
@@ -111,7 +123,9 @@
#djDebug button:active {
border: 1px solid #aaa;
border-bottom: 1px solid #888;
- box-shadow: inset 0 0 5px 2px #aaa, 0 1px 0 0 #eee;
+ box-shadow:
+ inset 0 0 5px 2px #aaa,
+ 0 1px 0 0 #eee;
}
#djDebug #djDebugToolbar {
@@ -179,7 +193,7 @@
#djDebug #djDebugToolbar li.djdt-active:before {
content: "▶";
- font-family: sans-serif;
+ font-family: var(--djdt-font-family-primary);
position: absolute;
left: 0;
top: 50%;
@@ -244,11 +258,7 @@
#djDebug pre,
#djDebug code {
display: block;
- font-family: ui-monospace, Menlo, Monaco, "Cascadia Mono", "Segoe UI Mono",
- "Roboto Mono", "Oxygen Mono", "Ubuntu Monospace", "Source Code Pro",
- "Fira Mono", "Droid Sans Mono", "Courier New", monospace,
- "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol",
- "Noto Color Emoji";
+ font-family: var(--djdt-font-family-monospace);
overflow: auto;
}
diff --git a/debug_toolbar/toolbar.py b/debug_toolbar/toolbar.py
index 40e758107..31010f47f 100644
--- a/debug_toolbar/toolbar.py
+++ b/debug_toolbar/toolbar.py
@@ -95,9 +95,13 @@ def should_render_panels(self):
If False, the panels will be loaded via Ajax.
"""
- render_panels = self.config["RENDER_PANELS"]
- if render_panels is None:
- render_panels = self.request.META["wsgi.multiprocess"]
+ 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
# Handle storing toolbars in memory and fetching them later on
diff --git a/docs/changes.rst b/docs/changes.rst
index bb348a36c..ad6607e34 100644
--- a/docs/changes.rst
+++ b/docs/changes.rst
@@ -4,10 +4,30 @@ Change log
Pending
-------
-4.0.0 (2023-03-14)
+4.1.0 (2023-05-15)
------------------
-* Added Django 4.2a1 to the CI.
+* Improved SQL statement formatting performance. Additionally, fixed the
+ indentation of ``CASE`` statements and stopped simplifying ``.count()``
+ queries.
+* Added support for the new STORAGES setting in Django 4.2 for static files.
+* Added support for theme overrides.
+* Reworked the cache panel instrumentation code to no longer attempt to undo
+ monkey patching of cache methods, as that turned out to be fragile in the
+ presence of other code which also monkey patches those methods.
+* Update all timing code that used :py:func:`time.time()` to use
+ :py:func:`time.perf_counter()` instead.
+* Made the check on ``request.META["wsgi.multiprocess"]`` optional, but
+ defaults to forcing the toolbar to render the panels on each request. This
+ is because it's likely an ASGI application that's serving the responses
+ and that's more likely to be an incompatible setup. If you find that this
+ is incorrect for you in particular, you can use the ``RENDER_PANELS``
+ setting to forcibly control this logic.
+
+4.0.0 (2023-04-03)
+------------------
+
+* Added Django 4.2 to the CI.
* Dropped support for Python 3.7.
* Fixed PostgreSQL raw query with a tuple parameter during on explain.
* Use ``TOOLBAR_LANGUAGE`` setting when rendering individual panels
diff --git a/docs/conf.py b/docs/conf.py
index 18b02f9f7..2e4886c9c 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -25,7 +25,7 @@
copyright = copyright.format(datetime.date.today().year)
# The full version, including alpha/beta/rc tags
-release = "4.0.0"
+release = "4.1.0"
# -- General configuration ---------------------------------------------------
@@ -59,8 +59,11 @@
# html_static_path = ['_static']
intersphinx_mapping = {
- "https://docs.python.org/": None,
- "https://docs.djangoproject.com/en/dev/": "https://docs.djangoproject.com/en/dev/_objects/",
+ "python": ("https://docs.python.org/", None),
+ "django": (
+ "https://docs.djangoproject.com/en/dev/",
+ "https://docs.djangoproject.com/en/dev/_objects/",
+ ),
}
# -- Options for Read the Docs -----------------------------------------------
diff --git a/docs/configuration.rst b/docs/configuration.rst
index 86cb65ce4..887608c6e 100644
--- a/docs/configuration.rst
+++ b/docs/configuration.rst
@@ -338,3 +338,26 @@ Here's what a slightly customized toolbar configuration might look like::
# Panel options
'SQL_WARNING_THRESHOLD': 100, # milliseconds
}
+
+Theming support
+---------------
+The debug toolbar uses CSS variables to define fonts. This allows changing
+fonts without having to override many individual CSS rules. For example, if
+you preferred Roboto instead of the default list of fonts you could add a
+**debug_toolbar/base.html** template override to your project:
+
+.. code-block:: django
+
+ {% extends 'debug_toolbar/base.html' %}
+
+ {% block css %}{{ block.super }}
+
+ {% endblock %}
+
+The list of CSS variables are defined at
+`debug_toolbar/static/debug_toolbar/css/toolbar.css
+