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 +`_ diff --git a/docs/installation.rst b/docs/installation.rst index acc017601..3b65ff8e2 100644 --- a/docs/installation.rst +++ b/docs/installation.rst @@ -10,7 +10,9 @@ fully functional. 1. Install the Package ^^^^^^^^^^^^^^^^^^^^^^ -The recommended way to install the Debug Toolbar is via pip_:: +The recommended way to install the Debug Toolbar is via pip_: + +.. code-block:: console $ python -m pip install django-debug-toolbar @@ -20,9 +22,11 @@ If you aren't familiar with pip, you may also obtain a copy of the .. _pip: https://pip.pypa.io/ To test an upcoming release, you can install the in-development version -instead with the following command:: +instead with the following command: + +.. code-block:: console - $ python -m pip install -e git+https://github.com/jazzband/django-debug-toolbar.git#egg=django-debug-toolbar + $ python -m pip install -e git+https://github.com/jazzband/django-debug-toolbar.git#egg=django-debug-toolbar If you're upgrading from a previous version, you should review the :doc:`change log ` and look for specific upgrade instructions. @@ -64,7 +68,9 @@ Second, ensure that your ``TEMPLATES`` setting contains a 3. Install the App ^^^^^^^^^^^^^^^^^^ -Add ``"debug_toolbar"`` to your ``INSTALLED_APPS`` setting:: +Add ``"debug_toolbar"`` to your ``INSTALLED_APPS`` setting: + +.. code-block:: python INSTALLED_APPS = [ # ... @@ -83,7 +89,7 @@ Add django-debug-toolbar's URLs to your project's URLconf: urlpatterns = [ # ... - path('__debug__/', include('debug_toolbar.urls')), + path("__debug__/", include("debug_toolbar.urls")), ] This example uses the ``__debug__`` prefix, but you can use any prefix that diff --git a/docs/spelling_wordlist.txt b/docs/spelling_wordlist.txt index 1741b405b..d5aa73afe 100644 --- a/docs/spelling_wordlist.txt +++ b/docs/spelling_wordlist.txt @@ -40,12 +40,14 @@ Pympler querysets refactoring resizing +Roboto spellchecking spooler stacktrace stacktraces startup timeline +theming tox Transifex unhashable diff --git a/pyproject.toml b/pyproject.toml index 1c820c417..8729cb911 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,24 +14,23 @@ authors = [ ] requires-python = ">=3.8" classifiers = [ - "Development Status :: 5 - Production/Stable", - "Environment :: Web Environment", - "Framework :: Django", - "Framework :: Django :: 3.2", - "Framework :: Django :: 4.0", - "Framework :: Django :: 4.1", - "Framework :: Django :: 4.2", - "Intended Audience :: Developers", - "License :: OSI Approved :: BSD License", - "Operating System :: OS Independent", - "Programming Language :: Python", - "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3 :: Only", - "Programming Language :: Python :: 3.8", - "Programming Language :: Python :: 3.9", - "Programming Language :: Python :: 3.10", - "Programming Language :: Python :: 3.11", - "Topic :: Software Development :: Libraries :: Python Modules", + "Development Status :: 5 - Production/Stable", + "Environment :: Web Environment", + "Framework :: Django", + "Framework :: Django :: 3.2", + "Framework :: Django :: 4.0", + "Framework :: Django :: 4.1", + "Framework :: Django :: 4.2", + "Intended Audience :: Developers", + "License :: OSI Approved :: BSD License", + "Operating System :: OS Independent", + "Programming Language :: Python", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Topic :: Software Development :: Libraries :: Python Modules", ] dynamic = [ "version", @@ -74,5 +73,5 @@ source = ["src", ".tox/*/site-packages"] [tool.coverage.report] # Update coverage badge link in README.rst when fail_under changes -fail_under = 93 +fail_under = 94 show_missing = true diff --git a/tests/panels/test_sql.py b/tests/panels/test_sql.py index 13e3625ba..7b3452935 100644 --- a/tests/panels/test_sql.py +++ b/tests/panels/test_sql.py @@ -14,7 +14,6 @@ from django.test.utils import override_settings import debug_toolbar.panels.sql.tracking as sql_tracking -from debug_toolbar import settings as dt_settings try: import psycopg @@ -458,42 +457,92 @@ def test_regression_infinite_recursion(self): # ensure the stacktrace is populated self.assertTrue(len(query["stacktrace"]) > 0) - @override_settings( - DEBUG_TOOLBAR_CONFIG={"PRETTIFY_SQL": True}, - ) def test_prettify_sql(self): """ Test case to validate that the PRETTIFY_SQL setting changes the output of the sql when it's toggled. It does not validate what it does though. """ - list(User.objects.filter(username__istartswith="spam")) - - response = self.panel.process_request(self.request) - self.panel.generate_stats(self.request, response) - pretty_sql = self.panel._queries[-1]["sql"] - self.assertEqual(len(self.panel._queries), 1) + with override_settings(DEBUG_TOOLBAR_CONFIG={"PRETTIFY_SQL": True}): + list(User.objects.filter(username__istartswith="spam")) + response = self.panel.process_request(self.request) + self.panel.generate_stats(self.request, response) + pretty_sql = self.panel._queries[-1]["sql"] + self.assertEqual(len(self.panel._queries), 1) # Reset the queries self.panel._queries = [] # Run it again, but with prettify off. Verify that it's different. - dt_settings.get_config()["PRETTIFY_SQL"] = False - list(User.objects.filter(username__istartswith="spam")) - response = self.panel.process_request(self.request) - self.panel.generate_stats(self.request, response) - self.assertEqual(len(self.panel._queries), 1) - self.assertNotEqual(pretty_sql, self.panel._queries[-1]["sql"]) + with override_settings(DEBUG_TOOLBAR_CONFIG={"PRETTIFY_SQL": False}): + list(User.objects.filter(username__istartswith="spam")) + response = self.panel.process_request(self.request) + self.panel.generate_stats(self.request, response) + self.assertEqual(len(self.panel._queries), 1) + self.assertNotEqual(pretty_sql, self.panel._queries[-1]["sql"]) self.panel._queries = [] # Run it again, but with prettify back on. # This is so we don't have to check what PRETTIFY_SQL does exactly, # but we know it's doing something. - dt_settings.get_config()["PRETTIFY_SQL"] = True - list(User.objects.filter(username__istartswith="spam")) + with override_settings(DEBUG_TOOLBAR_CONFIG={"PRETTIFY_SQL": True}): + list(User.objects.filter(username__istartswith="spam")) + response = self.panel.process_request(self.request) + self.panel.generate_stats(self.request, response) + self.assertEqual(len(self.panel._queries), 1) + self.assertEqual(pretty_sql, self.panel._queries[-1]["sql"]) + + def test_simplification(self): + """ + Test case to validate that select lists for .count() and .exist() queries do not + get elided, but other select lists do. + """ + User.objects.count() + User.objects.exists() + list(User.objects.values_list("id")) response = self.panel.process_request(self.request) self.panel.generate_stats(self.request, response) - self.assertEqual(len(self.panel._queries), 1) - self.assertEqual(pretty_sql, self.panel._queries[-1]["sql"]) + self.assertEqual(len(self.panel._queries), 3) + self.assertNotIn("\u2022", self.panel._queries[0]["sql"]) + self.assertNotIn("\u2022", self.panel._queries[1]["sql"]) + self.assertIn("\u2022", self.panel._queries[2]["sql"]) + + def test_top_level_simplification(self): + """ + Test case to validate that top-level select lists get elided, but other select + lists for subselects do not. + """ + list(User.objects.filter(id__in=User.objects.filter(is_staff=True))) + list(User.objects.filter(id__lt=20).union(User.objects.filter(id__gt=10))) + if connection.vendor != "mysql": + list( + User.objects.filter(id__lt=20).intersection( + User.objects.filter(id__gt=10) + ) + ) + list( + User.objects.filter(id__lt=20).difference( + User.objects.filter(id__gt=10) + ) + ) + response = self.panel.process_request(self.request) + self.panel.generate_stats(self.request, response) + if connection.vendor != "mysql": + self.assertEqual(len(self.panel._queries), 4) + else: + self.assertEqual(len(self.panel._queries), 2) + # WHERE ... IN SELECT ... queries should have only one elided select list + self.assertEqual(self.panel._queries[0]["sql"].count("SELECT"), 4) + self.assertEqual(self.panel._queries[0]["sql"].count("\u2022"), 3) + # UNION queries should have two elidid select lists + self.assertEqual(self.panel._queries[1]["sql"].count("SELECT"), 4) + self.assertEqual(self.panel._queries[1]["sql"].count("\u2022"), 6) + if connection.vendor != "mysql": + # INTERSECT queries should have two elidid select lists + self.assertEqual(self.panel._queries[2]["sql"].count("SELECT"), 4) + self.assertEqual(self.panel._queries[2]["sql"].count("\u2022"), 6) + # EXCEPT queries should have two elidid select lists + self.assertEqual(self.panel._queries[3]["sql"].count("SELECT"), 4) + self.assertEqual(self.panel._queries[3]["sql"].count("\u2022"), 6) @override_settings( DEBUG=True, diff --git a/tests/test_integration.py b/tests/test_integration.py index b292dcbf0..71340709a 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -65,6 +65,35 @@ def test_show_toolbar_INTERNAL_IPS(self): with self.settings(INTERNAL_IPS=[]): self.assertFalse(show_toolbar(self.request)) + def test_should_render_panels_RENDER_PANELS(self): + """ + The toolbar should force rendering panels on each request + based on the RENDER_PANELS setting. + """ + toolbar = DebugToolbar(self.request, self.get_response) + self.assertFalse(toolbar.should_render_panels()) + toolbar.config["RENDER_PANELS"] = True + self.assertTrue(toolbar.should_render_panels()) + toolbar.config["RENDER_PANELS"] = None + self.assertTrue(toolbar.should_render_panels()) + + def test_should_render_panels_multiprocess(self): + """ + The toolbar should render the panels on each request when wsgi.multiprocess + is True or missing. + """ + request = rf.get("/") + request.META["wsgi.multiprocess"] = True + toolbar = DebugToolbar(request, self.get_response) + toolbar.config["RENDER_PANELS"] = None + self.assertTrue(toolbar.should_render_panels()) + + request.META["wsgi.multiprocess"] = False + self.assertFalse(toolbar.should_render_panels()) + + request.META.pop("wsgi.multiprocess") + self.assertTrue(toolbar.should_render_panels()) + def _resolve_stats(self, path): # takes stats from Request panel self.request.path = path