diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 172c4ddeb..2a0e179ad 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.2.0 + rev: v4.3.0 hooks: - id: check-yaml - id: end-of-file-fixer @@ -11,16 +11,16 @@ repos: hooks: - id: flake8 - repo: https://github.com/pycqa/doc8 - rev: 0.11.1 + rev: 0.11.2 hooks: - id: doc8 - repo: https://github.com/asottile/pyupgrade - rev: v2.32.0 + rev: v2.34.0 hooks: - id: pyupgrade args: [--py37-plus] - repo: https://github.com/adamchainz/django-upgrade - rev: 1.5.0 + rev: 1.7.0 hooks: - id: django-upgrade args: [--target-version, "3.2"] @@ -38,12 +38,12 @@ repos: - id: rst-backticks - id: rst-directive-colons - repo: https://github.com/pre-commit/mirrors-prettier - rev: v2.6.2 + rev: v2.7.1 hooks: - id: prettier types_or: [javascript, css] - repo: https://github.com/pre-commit/mirrors-eslint - rev: v8.14.0 + rev: v8.18.0 hooks: - id: eslint files: \.js?$ diff --git a/README.rst b/README.rst index d146726d5..2c1ba9730 100644 --- a/README.rst +++ b/README.rst @@ -5,7 +5,7 @@ Django Debug Toolbar |latest-version| |jazzband| |build-status| |coverage| |docs| |python-support| |django-support| .. |latest-version| image:: https://img.shields.io/pypi/v/django-debug-toolbar.svg - :target: https://pypi.python.org/pypi/django-debug-toolbar + :target: https://pypi.org/project/django-debug-toolbar/ :alt: Latest version on PyPI .. |jazzband| image:: https://jazzband.co/static/img/badge.svg @@ -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-89%25-green +.. |coverage| image:: https://img.shields.io/badge/Coverage-93%25-green :target: https://github.com/jazzband/django-debug-toolbar/actions/workflows/test.yml?query=branch%3Amain :alt: Test coverage status @@ -25,11 +25,11 @@ Django Debug Toolbar |latest-version| :alt: Documentation status .. |python-support| image:: https://img.shields.io/pypi/pyversions/django-debug-toolbar - :target: https://pypi.python.org/pypi/django-debug-toolbar + :target: https://pypi.org/project/django-debug-toolbar/ :alt: Supported Python versions .. |django-support| image:: https://img.shields.io/pypi/djversions/django-debug-toolbar - :target: https://pypi.org/project/django-debug-toolbar + :target: https://pypi.org/project/django-debug-toolbar/ :alt: Supported Django versions The Django Debug Toolbar is a configurable set of panels that display various @@ -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 3.4.0. It works on +The current stable version of the Debug Toolbar is 3.5.0. It works on Django ≥ 3.2. Documentation, including installation and configuration instructions, is diff --git a/debug_toolbar/__init__.py b/debug_toolbar/__init__.py index e085bea73..c9834b8e3 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 = "3.4.0" +VERSION = "3.5.0" # Code that discovers files or modules in INSTALLED_APPS imports this module. urls = "debug_toolbar.urls", APP_NAME diff --git a/debug_toolbar/apps.py b/debug_toolbar/apps.py index 2848e72d5..c55b75392 100644 --- a/debug_toolbar/apps.py +++ b/debug_toolbar/apps.py @@ -17,9 +17,10 @@ class DebugToolbarConfig(AppConfig): def ready(self): from debug_toolbar.toolbar import DebugToolbar - # Import the panels when the app is ready. This allows panels - # like CachePanel to enable the instrumentation immediately. - DebugToolbar.get_panel_classes() + # Import the panels when the app is ready and call their ready() methods. This + # allows panels like CachePanel to enable their instrumentation immediately. + for cls in DebugToolbar.get_panel_classes(): + cls.ready() def check_template_config(config): diff --git a/debug_toolbar/forms.py b/debug_toolbar/forms.py index 2b4e048b4..3c7a45a07 100644 --- a/debug_toolbar/forms.py +++ b/debug_toolbar/forms.py @@ -47,7 +47,6 @@ def verified_data(self): @classmethod def sign(cls, data): - items = sorted(data.items(), key=lambda item: item[0]) return signing.Signer(salt=cls.salt).sign( - json.dumps({key: force_str(value) for key, value in items}) + json.dumps({key: force_str(value) for key, value in data.items()}) ) diff --git a/debug_toolbar/middleware.py b/debug_toolbar/middleware.py index f131861fc..f62904cf9 100644 --- a/debug_toolbar/middleware.py +++ b/debug_toolbar/middleware.py @@ -10,6 +10,7 @@ from debug_toolbar import settings as dt_settings from debug_toolbar.toolbar import DebugToolbar +from debug_toolbar.utils import clear_stack_trace_caches _HTML_TYPES = ("text/html", "application/xhtml+xml") @@ -56,6 +57,7 @@ def __call__(self, request): # Run panels like Django middleware. response = toolbar.process_request(request) finally: + clear_stack_trace_caches() # Deactivate instrumentation ie. monkey-unpatch. This must run # regardless of the response. Keep 'return' clauses below. for panel in reversed(toolbar.enabled_panels): diff --git a/debug_toolbar/panels/__init__.py b/debug_toolbar/panels/__init__.py index 168166bc6..ea8ff8e9c 100644 --- a/debug_toolbar/panels/__init__.py +++ b/debug_toolbar/panels/__init__.py @@ -114,6 +114,19 @@ def scripts(self): """ return [] + # Panel early initialization + + @classmethod + def ready(cls): + """ + Perform early initialization for the panel. + + This should only include initialization or instrumentation that needs to + be done unconditionally for the panel regardless of whether it is + enabled for a particular request. It should be idempotent. + """ + pass + # URLs for panel-specific views @classmethod diff --git a/debug_toolbar/panels/cache.py b/debug_toolbar/panels/cache.py index 74b2f3ab6..f5ceea513 100644 --- a/debug_toolbar/panels/cache.py +++ b/debug_toolbar/panels/cache.py @@ -1,158 +1,33 @@ -import inspect -import sys +import functools import time -from collections import OrderedDict - -try: - from django.utils.connection import ConnectionProxy -except ImportError: - ConnectionProxy = None +from asgiref.local import Local from django.conf import settings -from django.core import cache -from django.core.cache import DEFAULT_CACHE_ALIAS, CacheHandler -from django.core.cache.backends.base import BaseCache -from django.dispatch import Signal -from django.middleware import cache as middleware_cache +from django.core.cache import CacheHandler, caches from django.utils.translation import gettext_lazy as _, ngettext -from debug_toolbar import settings as dt_settings from debug_toolbar.panels import Panel -from debug_toolbar.utils import ( - get_stack, - get_template_info, - render_stacktrace, - tidy_stacktrace, -) - -cache_called = Signal() - - -def send_signal(method): - def wrapped(self, *args, **kwargs): - t = time.time() - value = method(self, *args, **kwargs) - t = time.time() - t - - if dt_settings.get_config()["ENABLE_STACKTRACES"]: - stacktrace = tidy_stacktrace(reversed(get_stack())) - else: - stacktrace = [] - - template_info = get_template_info() - cache_called.send( - sender=self.__class__, - time_taken=t, - name=method.__name__, - return_value=value, - args=args, - kwargs=kwargs, - trace=stacktrace, - template_info=template_info, - backend=self.cache, - ) - return value - - return wrapped - - -class CacheStatTracker(BaseCache): - """A small class used to track cache calls.""" - - def __init__(self, cache): - self.cache = cache - - def __repr__(self): - return "" % repr(self.cache) - - def _get_func_info(self): - frame = sys._getframe(3) - info = inspect.getframeinfo(frame) - return (info[0], info[1], info[2], info[3]) - - def __contains__(self, key): - return self.cache.__contains__(key) - - def __getattr__(self, name): - return getattr(self.cache, name) - - @send_signal - def add(self, *args, **kwargs): - return self.cache.add(*args, **kwargs) - - @send_signal - def get(self, *args, **kwargs): - return self.cache.get(*args, **kwargs) - - @send_signal - def set(self, *args, **kwargs): - return self.cache.set(*args, **kwargs) - - @send_signal - def get_or_set(self, *args, **kwargs): - return self.cache.get_or_set(*args, **kwargs) - - @send_signal - def touch(self, *args, **kwargs): - return self.cache.touch(*args, **kwargs) - - @send_signal - def delete(self, *args, **kwargs): - return self.cache.delete(*args, **kwargs) - - @send_signal - def clear(self, *args, **kwargs): - return self.cache.clear(*args, **kwargs) - - @send_signal - def has_key(self, *args, **kwargs): - # Ignore flake8 rules for has_key since we need to support caches - # that may be using has_key. - return self.cache.has_key(*args, **kwargs) # noqa: W601 - - @send_signal - def incr(self, *args, **kwargs): - return self.cache.incr(*args, **kwargs) - - @send_signal - def decr(self, *args, **kwargs): - return self.cache.decr(*args, **kwargs) - - @send_signal - def get_many(self, *args, **kwargs): - return self.cache.get_many(*args, **kwargs) - - @send_signal - def set_many(self, *args, **kwargs): - self.cache.set_many(*args, **kwargs) - - @send_signal - def delete_many(self, *args, **kwargs): - self.cache.delete_many(*args, **kwargs) - - @send_signal - def incr_version(self, *args, **kwargs): - return self.cache.incr_version(*args, **kwargs) - - @send_signal - def decr_version(self, *args, **kwargs): - return self.cache.decr_version(*args, **kwargs) - - -class CacheHandlerPatch(CacheHandler): - def __init__(self, settings=None): - self._djdt_wrap = True - super().__init__(settings=settings) - - def create_connection(self, alias): - actual_cache = super().create_connection(alias) - if self._djdt_wrap: - return CacheStatTracker(actual_cache) - else: - return actual_cache - - -middleware_cache.caches = CacheHandlerPatch() +from debug_toolbar.utils import get_stack_trace, get_template_info, render_stacktrace + +# The order of the methods in this list determines the order in which they are listed in +# the Commands table in the panel content. +WRAPPED_CACHE_METHODS = [ + "add", + "get", + "set", + "get_or_set", + "touch", + "delete", + "clear", + "get_many", + "set_many", + "delete_many", + "has_key", + "incr", + "decr", + "incr_version", + "decr_version", +] class CachePanel(Panel): @@ -162,45 +37,57 @@ class CachePanel(Panel): template = "debug_toolbar/panels/cache.html" + _context_locals = Local() + def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.total_time = 0 self.hits = 0 self.misses = 0 self.calls = [] - self.counts = OrderedDict( - ( - ("add", 0), - ("get", 0), - ("set", 0), - ("get_or_set", 0), - ("touch", 0), - ("delete", 0), - ("clear", 0), - ("get_many", 0), - ("set_many", 0), - ("delete_many", 0), - ("has_key", 0), - ("incr", 0), - ("decr", 0), - ("incr_version", 0), - ("decr_version", 0), - ) - ) - cache_called.connect(self._store_call_info) + self.counts = {name: 0 for name in WRAPPED_CACHE_METHODS} + + @classmethod + def current_instance(cls): + """ + Return the currently enabled CachePanel instance or None. + + If a request is in process with a CachePanel enabled, this will return that + panel (based on the current thread or async task). Otherwise it will return + None. + """ + return getattr(cls._context_locals, "current_instance", None) + + @classmethod + def ready(cls): + if not hasattr(CacheHandler, "_djdt_patched"): + # Wrap the CacheHander.create_connection() method to monkey patch any new + # cache connections that are opened while instrumentation is enabled. In + # the interests of thread safety, this is done once at startup time and + # never removed. + original_method = CacheHandler.create_connection + + @functools.wraps(original_method) + def wrapper(self, alias): + cache = original_method(self, alias) + panel = cls.current_instance() + if panel is not None: + panel._monkey_patch_cache(cache) + return cache + + CacheHandler.create_connection = wrapper + CacheHandler._djdt_patched = True def _store_call_info( self, - sender, - name=None, - time_taken=0, - return_value=None, - args=None, - kwargs=None, - trace=None, - template_info=None, - backend=None, - **kw, + name, + time_taken, + return_value, + args, + kwargs, + trace, + template_info, + backend, ): if name == "get" or name == "get_or_set": if return_value is None: @@ -208,11 +95,12 @@ def _store_call_info( else: self.hits += 1 elif name == "get_many": - for key, value in return_value.items(): - if value is None: - self.misses += 1 - else: - self.hits += 1 + if "keys" in kwargs: + keys = kwargs["keys"] + else: + keys = args[0] + self.hits += len(return_value) + self.misses += len(keys) - len(return_value) time_taken *= 1000 self.total_time += time_taken @@ -229,6 +117,64 @@ 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 + + self._store_call_info( + name=name, + time_taken=t, + return_value=value, + args=args, + kwargs=kwargs, + trace=get_stack_trace(skip=2), + template_info=get_template_info(), + backend=cache, + ) + 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") @@ -252,26 +198,23 @@ def title(self): ) % {"count": count} def enable_instrumentation(self): - for alias in cache.caches: - if not isinstance(cache.caches[alias], CacheStatTracker): - cache.caches[alias] = CacheStatTracker(cache.caches[alias]) - - if not isinstance(middleware_cache.caches, CacheHandlerPatch): - middleware_cache.caches = cache.caches - - # Wrap the patched cache inside Django's ConnectionProxy - if ConnectionProxy: - cache.cache = ConnectionProxy(cache.caches, DEFAULT_CACHE_ALIAS) + # Monkey patch all open cache connections. Django maintains cache connections + # on a per-thread/async task basis, so this will not affect any concurrent + # requests. The monkey patch of CacheHander.create_connection() installed in + # 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) + # 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. + self._context_locals.current_instance = self def disable_instrumentation(self): - for alias in cache.caches: - if isinstance(cache.caches[alias], CacheStatTracker): - cache.caches[alias] = cache.caches[alias].cache - if ConnectionProxy: - cache.cache = ConnectionProxy(cache.caches, DEFAULT_CACHE_ALIAS) - # While it can be restored to the original, any views that were - # wrapped with the cache_page decorator will continue to use a - # monkey patched cache. + 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) def generate_stats(self, request, response): self.record_stats( diff --git a/debug_toolbar/panels/headers.py b/debug_toolbar/panels/headers.py index 280cc5df0..ed20d6178 100644 --- a/debug_toolbar/panels/headers.py +++ b/debug_toolbar/panels/headers.py @@ -1,5 +1,3 @@ -from collections import OrderedDict - from django.utils.translation import gettext_lazy as _ from debug_toolbar.panels import Panel @@ -36,21 +34,19 @@ class HeadersPanel(Panel): def process_request(self, request): wsgi_env = list(sorted(request.META.items())) - self.request_headers = OrderedDict( - (unmangle(k), v) for (k, v) in wsgi_env if is_http_header(k) - ) + self.request_headers = { + unmangle(k): v for (k, v) in wsgi_env if is_http_header(k) + } if "Cookie" in self.request_headers: self.request_headers["Cookie"] = "=> see Request panel" - self.environ = OrderedDict( - (k, v) for (k, v) in wsgi_env if k in self.ENVIRON_FILTER - ) + self.environ = {k: v for (k, v) in wsgi_env if k in self.ENVIRON_FILTER} self.record_stats( {"request_headers": self.request_headers, "environ": self.environ} ) return super().process_request(request) def generate_stats(self, request, response): - self.response_headers = OrderedDict(sorted(response.items())) + self.response_headers = dict(sorted(response.items())) self.record_stats({"response_headers": self.response_headers}) diff --git a/debug_toolbar/panels/history/panel.py b/debug_toolbar/panels/history/panel.py index 00b350b3c..596bcfb4a 100644 --- a/debug_toolbar/panels/history/panel.py +++ b/debug_toolbar/panels/history/panel.py @@ -1,5 +1,4 @@ import json -from collections import OrderedDict from django.http.request import RawPostDataException from django.template.loader import render_to_string @@ -87,7 +86,7 @@ def content(self): Fetch every store for the toolbar and include it in the template. """ - stores = OrderedDict() + stores = {} for id, toolbar in reversed(self.toolbar._store.items()): stores[id] = { "toolbar": toolbar, diff --git a/debug_toolbar/panels/request.py b/debug_toolbar/panels/request.py index 5255624b2..966301d97 100644 --- a/debug_toolbar/panels/request.py +++ b/debug_toolbar/panels/request.py @@ -61,9 +61,11 @@ def generate_stats(self, request, response): if hasattr(request, "session"): self.record_stats( { - "session": [ - (k, request.session.get(k)) - for k in sorted(request.session.keys()) - ] + "session": { + "list": [ + (k, request.session.get(k)) + for k in sorted(request.session.keys()) + ] + } } ) diff --git a/debug_toolbar/panels/settings.py b/debug_toolbar/panels/settings.py index 37bba8727..7b27c6243 100644 --- a/debug_toolbar/panels/settings.py +++ b/debug_toolbar/panels/settings.py @@ -1,5 +1,3 @@ -from collections import OrderedDict - from django.conf import settings from django.utils.translation import gettext_lazy as _ from django.views.debug import get_default_exception_reporter_filter @@ -22,10 +20,4 @@ def title(self): return _("Settings from %s") % settings.SETTINGS_MODULE def generate_stats(self, request, response): - self.record_stats( - { - "settings": OrderedDict( - sorted(get_safe_settings().items(), key=lambda s: s[0]) - ) - } - ) + self.record_stats({"settings": dict(sorted(get_safe_settings().items()))}) diff --git a/debug_toolbar/panels/signals.py b/debug_toolbar/panels/signals.py index 41f669f2c..574948d6e 100644 --- a/debug_toolbar/panels/signals.py +++ b/debug_toolbar/panels/signals.py @@ -76,7 +76,7 @@ def signals(self): def generate_stats(self, request, response): signals = [] - for name, signal in sorted(self.signals.items(), key=lambda x: x[0]): + for name, signal in sorted(self.signals.items()): receivers = [] for receiver in signal.receivers: receiver = receiver[1] diff --git a/debug_toolbar/panels/sql/panel.py b/debug_toolbar/panels/sql/panel.py index 00737a42d..d8099f25b 100644 --- a/debug_toolbar/panels/sql/panel.py +++ b/debug_toolbar/panels/sql/panel.py @@ -1,7 +1,6 @@ import uuid from collections import defaultdict from copy import copy -from pprint import saferepr from django.db import connections from django.urls import path @@ -48,6 +47,33 @@ def get_transaction_status_display(vendor, level): return choices.get(level) +def _similar_query_key(query): + return query["raw_sql"] + + +def _duplicate_query_key(query): + raw_params = () if query["raw_params"] is None else tuple(query["raw_params"]) + # repr() avoids problems because of unhashable types + # (e.g. lists) when used as dictionary keys. + # https://github.com/jazzband/django-debug-toolbar/issues/1091 + return (query["raw_sql"], repr(raw_params)) + + +def _process_query_groups(query_groups, databases, colors, name): + counts = defaultdict(int) + for (alias, key), query_group in query_groups.items(): + count = len(query_group) + # Queries are similar / duplicates only if there are at least 2 of them. + if count > 1: + color = next(colors) + for query in query_group: + query[f"{name}_count"] = count + query[f"{name}_color"] = color + counts[alias] += count + for alias, db_info in databases.items(): + db_info[f"{name}_count"] = counts[alias] + + class SQLPanel(Panel): """ Panel that displays information about the SQL queries run while processing @@ -61,38 +87,33 @@ def __init__(self, *args, **kwargs): self._num_queries = 0 self._queries = [] self._databases = {} - self._transaction_status = {} + # synthetic transaction IDs, keyed by DB alias self._transaction_ids = {} - def get_transaction_id(self, alias): - if alias not in connections: - return - conn = connections[alias].connection - if not conn: - return - - if conn.vendor == "postgresql": - cur_status = conn.get_transaction_status() - else: - raise ValueError(conn.vendor) - - last_status = self._transaction_status.get(alias) - self._transaction_status[alias] = cur_status - - if not cur_status: - # No available state - return None - - if cur_status != last_status: - if cur_status: - self._transaction_ids[alias] = uuid.uuid4().hex - else: - self._transaction_ids[alias] = None - - return self._transaction_ids[alias] - - def record(self, alias, **kwargs): - self._queries.append((alias, kwargs)) + def new_transaction_id(self, alias): + """ + Generate and return a new synthetic transaction ID for the specified DB alias. + """ + trans_id = uuid.uuid4().hex + self._transaction_ids[alias] = trans_id + return trans_id + + def current_transaction_id(self, alias): + """ + Return the current synthetic transaction ID for the specified DB alias. + """ + trans_id = self._transaction_ids.get(alias) + # Sometimes it is not possible to detect the beginning of the first transaction, + # so current_transaction_id() will be called before new_transaction_id(). In + # that case there won't yet be a transaction ID. so it is necessary to generate + # one using new_transaction_id(). + if trans_id is None: + trans_id = self.new_transaction_id(alias) + return trans_id + + def record(self, **kwargs): + self._queries.append(kwargs) + alias = kwargs["alias"] if alias not in self._databases: self._databases[alias] = { "time_spent": kwargs["duration"], @@ -150,21 +171,8 @@ def disable_instrumentation(self): def generate_stats(self, request, response): colors = contrasting_color_generator() trace_colors = defaultdict(lambda: next(colors)) - query_similar = defaultdict(lambda: defaultdict(int)) - query_duplicates = defaultdict(lambda: defaultdict(int)) - - # The keys used to determine similar and duplicate queries. - def similar_key(query): - return query["raw_sql"] - - def duplicate_key(query): - raw_params = ( - () if query["raw_params"] is None else tuple(query["raw_params"]) - ) - # saferepr() avoids problems because of unhashable types - # (e.g. lists) when used as dictionary keys. - # https://github.com/jazzband/django-debug-toolbar/issues/1091 - return (query["raw_sql"], saferepr(raw_params)) + similar_query_groups = defaultdict(list) + duplicate_query_groups = defaultdict(list) if self._queries: width_ratio_tally = 0 @@ -184,26 +192,31 @@ def duplicate_key(query): rgb[nn] = nc db["rgb_color"] = rgb - trans_ids = {} - trans_id = None - i = 0 - for alias, query in self._queries: - query_similar[alias][similar_key(query)] += 1 - query_duplicates[alias][duplicate_key(query)] += 1 + # the last query recorded for each DB alias + last_by_alias = {} + for query in self._queries: + alias = query["alias"] - trans_id = query.get("trans_id") - last_trans_id = trans_ids.get(alias) + similar_query_groups[(alias, _similar_query_key(query))].append(query) + duplicate_query_groups[(alias, _duplicate_query_key(query))].append( + query + ) - if trans_id != last_trans_id: - if last_trans_id: - self._queries[(i - 1)][1]["ends_trans"] = True - trans_ids[alias] = trans_id - if trans_id: + trans_id = query.get("trans_id") + prev_query = last_by_alias.get(alias, {}) + prev_trans_id = prev_query.get("trans_id") + + # If two consecutive queries for a given DB alias have different + # transaction ID values, a transaction started, finished, or both, so + # annotate the queries as appropriate. + if trans_id != prev_trans_id: + if prev_trans_id is not None: + prev_query["ends_trans"] = True + if trans_id is not None: query["starts_trans"] = True - if trans_id: + if trans_id is not None: query["in_trans"] = True - query["alias"] = alias if "iso_level" in query: query["iso_level"] = get_isolation_level_display( query["vendor"], query["iso_level"] @@ -228,62 +241,31 @@ def duplicate_key(query): query["end_offset"] = query["width_ratio"] + query["start_offset"] width_ratio_tally += query["width_ratio"] query["stacktrace"] = render_stacktrace(query["stacktrace"]) - i += 1 query["trace_color"] = trace_colors[query["stacktrace"]] - if trans_id: - self._queries[(i - 1)][1]["ends_trans"] = True - - # Queries are similar / duplicates only if there's as least 2 of them. - # Also, to hide queries, we need to give all the duplicate groups an id - query_colors = contrasting_color_generator() - query_similar_colors = { - alias: { - query: (similar_count, next(query_colors)) - for query, similar_count in queries.items() - if similar_count >= 2 - } - for alias, queries in query_similar.items() - } - query_duplicates_colors = { - alias: { - query: (duplicate_count, next(query_colors)) - for query, duplicate_count in queries.items() - if duplicate_count >= 2 - } - for alias, queries in query_duplicates.items() - } + last_by_alias[alias] = query - for alias, query in self._queries: - try: - (query["similar_count"], query["similar_color"]) = query_similar_colors[ - alias - ][similar_key(query)] - ( - query["duplicate_count"], - query["duplicate_color"], - ) = query_duplicates_colors[alias][duplicate_key(query)] - except KeyError: - pass - - for alias, alias_info in self._databases.items(): - try: - alias_info["similar_count"] = sum( - e[0] for e in query_similar_colors[alias].values() - ) - alias_info["duplicate_count"] = sum( - e[0] for e in query_duplicates_colors[alias].values() - ) - except KeyError: - pass + # Close out any transactions that were in progress, since there is no + # explicit way to know when a transaction finishes. + for final_query in last_by_alias.values(): + if final_query.get("trans_id") is not None: + final_query["ends_trans"] = True + + group_colors = contrasting_color_generator() + _process_query_groups( + similar_query_groups, self._databases, group_colors, "similar" + ) + _process_query_groups( + duplicate_query_groups, self._databases, group_colors, "duplicate" + ) self.record_stats( { "databases": sorted( self._databases.items(), key=lambda x: -x[1]["time_spent"] ), - "queries": [q for a, q in self._queries], + "queries": self._queries, "sql_time": self._sql_time, } ) diff --git a/debug_toolbar/panels/sql/tracking.py b/debug_toolbar/panels/sql/tracking.py index 93304b21f..b166e592d 100644 --- a/debug_toolbar/panels/sql/tracking.py +++ b/debug_toolbar/panels/sql/tracking.py @@ -6,12 +6,14 @@ from django.utils.encoding import force_str from debug_toolbar import settings as dt_settings -from debug_toolbar.utils import get_stack, get_template_info, tidy_stacktrace +from debug_toolbar.utils import get_stack_trace, get_template_info try: from psycopg2._json import Json as PostgresJson + from psycopg2.extensions import STATUS_IN_TRANSACTION except ImportError: PostgresJson = None + STATUS_IN_TRANSACTION = None # Prevents SQL queries from being sent to the DB. It's used # by the TemplatePanel to prevent the toolbar from issuing @@ -60,9 +62,22 @@ def chunked_cursor(*args, **kwargs): 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 - del connection.cursor - del connection.chunked_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: @@ -126,16 +141,20 @@ def _decode(self, param): return "(encoded string)" def _record(self, method, sql, params): + alias = self.db.alias + vendor = self.db.vendor + + if vendor == "postgresql": + # The underlying DB connection (as opposed to Django's wrapper) + conn = self.db.connection + initial_conn_status = conn.status + start_time = time() try: return method(sql, params) finally: stop_time = time() duration = (stop_time - start_time) * 1000 - if dt_settings.get_config()["ENABLE_STACKTRACES"]: - stacktrace = tidy_stacktrace(reversed(get_stack())) - else: - stacktrace = [] _params = "" try: _params = json.dumps(self._decode(params)) @@ -143,10 +162,6 @@ def _record(self, method, sql, params): pass # object not JSON serializable template_info = get_template_info() - alias = getattr(self.db, "alias", "default") - conn = self.db.connection - vendor = getattr(conn, "vendor", "unknown") - # Sql might be an object (such as psycopg Composed). # For logging purposes, make sure it's str. sql = str(sql) @@ -161,7 +176,7 @@ def _record(self, method, sql, params): "raw_sql": sql, "params": _params, "raw_params": params, - "stacktrace": stacktrace, + "stacktrace": get_stack_trace(skip=2), "start_time": start_time, "stop_time": stop_time, "is_slow": duration > dt_settings.get_config()["SQL_WARNING_THRESHOLD"], @@ -177,12 +192,31 @@ def _record(self, method, sql, params): iso_level = conn.isolation_level except conn.InternalError: iso_level = "unknown" + # PostgreSQL does not expose any sort of transaction ID, so it is + # necessary to generate synthetic transaction IDs here. If the + # connection was not in a transaction when the query started, and was + # after the query finished, a new transaction definitely started, so get + # a new transaction ID from logger.new_transaction_id(). If the query + # was in a transaction both before and after executing, make the + # assumption that it is the same transaction and get the current + # transaction ID from logger.current_transaction_id(). There is an edge + # case where Django can start a transaction before the first query + # executes, so in that case logger.current_transaction_id() will + # generate a new transaction ID since one does not already exist. + final_conn_status = conn.status + if final_conn_status == STATUS_IN_TRANSACTION: + if initial_conn_status == STATUS_IN_TRANSACTION: + trans_id = self.logger.current_transaction_id(alias) + else: + trans_id = self.logger.new_transaction_id(alias) + else: + trans_id = None + params.update( { - "trans_id": self.logger.get_transaction_id(alias), + "trans_id": trans_id, "trans_status": conn.get_transaction_status(), "iso_level": iso_level, - "encoding": conn.encoding, } ) diff --git a/debug_toolbar/panels/staticfiles.py b/debug_toolbar/panels/staticfiles.py index d90b6501a..c386ee145 100644 --- a/debug_toolbar/panels/staticfiles.py +++ b/debug_toolbar/panels/staticfiles.py @@ -1,4 +1,3 @@ -from collections import OrderedDict from os.path import join, normpath from django.conf import settings @@ -137,7 +136,7 @@ def get_staticfiles_finders(self): of relative and file system paths which that finder was able to find. """ - finders_mapping = OrderedDict() + finders_mapping = {} for finder in finders.get_finders(): try: for path, finder_storage in finder.list([]): diff --git a/debug_toolbar/panels/templates/panel.py b/debug_toolbar/panels/templates/panel.py index 1c2c96e09..35d5b5191 100644 --- a/debug_toolbar/panels/templates/panel.py +++ b/debug_toolbar/panels/templates/panel.py @@ -1,4 +1,3 @@ -from collections import OrderedDict from contextlib import contextmanager from os.path import normpath from pprint import pformat, saferepr @@ -39,7 +38,7 @@ def _request_context_bind_template(self, template): self.template = template # Set context processors according to the template engine's settings. processors = template.engine.template_context_processors + self._processors - self.context_processors = OrderedDict() + self.context_processors = {} updates = {} for processor in processors: name = f"{processor.__module__}.{processor.__name__}" diff --git a/debug_toolbar/static/debug_toolbar/css/toolbar.css b/debug_toolbar/static/debug_toolbar/css/toolbar.css index 2d36049f1..a105bfd11 100644 --- a/debug_toolbar/static/debug_toolbar/css/toolbar.css +++ b/debug_toolbar/static/debug_toolbar/css/toolbar.css @@ -591,6 +591,13 @@ #djDebug .djdt-stack pre.djdt-locals { margin: 0 27px 27px 27px; } +#djDebug .djdt-raw { + background-color: #fff; + border: 1px solid #ccc; + margin-top: 0.8em; + padding: 5px; + white-space: pre-wrap; +} #djDebug .djdt-width-20 { width: 20%; diff --git a/debug_toolbar/templates/debug_toolbar/panels/request.html b/debug_toolbar/templates/debug_toolbar/panels/request.html index 3f9b068be..076d5f74f 100644 --- a/debug_toolbar/templates/debug_toolbar/panels/request.html +++ b/debug_toolbar/templates/debug_toolbar/panels/request.html @@ -20,28 +20,28 @@

{% trans "View information" %}

-{% if cookies %} +{% if cookies.list or cookies.raw %}

{% trans "Cookies" %}

{% include 'debug_toolbar/panels/request_variables.html' with variables=cookies %} {% else %}

{% trans "No cookies" %}

{% endif %} -{% if session %} +{% if session.list or session.raw %}

{% trans "Session data" %}

{% include 'debug_toolbar/panels/request_variables.html' with variables=session %} {% else %}

{% trans "No session data" %}

{% endif %} -{% if get %} +{% if get.list or get.raw %}

{% trans "GET data" %}

{% include 'debug_toolbar/panels/request_variables.html' with variables=get %} {% else %}

{% trans "No GET data" %}

{% endif %} -{% if post %} +{% if post.list or post.raw %}

{% trans "POST data" %}

{% include 'debug_toolbar/panels/request_variables.html' with variables=post %} {% else %} diff --git a/debug_toolbar/templates/debug_toolbar/panels/request_variables.html b/debug_toolbar/templates/debug_toolbar/panels/request_variables.html index 7e9118c7d..92200f867 100644 --- a/debug_toolbar/templates/debug_toolbar/panels/request_variables.html +++ b/debug_toolbar/templates/debug_toolbar/panels/request_variables.html @@ -1,5 +1,6 @@ {% load i18n %} +{% if variables.list %} @@ -12,7 +13,7 @@ - {% for key, value in variables %} + {% for key, value in variables.list %} @@ -20,3 +21,6 @@ {% endfor %}
{{ key|pprint }} {{ value|pprint }}
+{% elif variables.raw %} +{{ variables.raw|pprint }} +{% endif %} diff --git a/debug_toolbar/toolbar.py b/debug_toolbar/toolbar.py index 79e5ac1c7..419c67418 100644 --- a/debug_toolbar/toolbar.py +++ b/debug_toolbar/toolbar.py @@ -8,6 +8,7 @@ from django.apps import apps from django.core.exceptions import ImproperlyConfigured +from django.dispatch import Signal from django.template import TemplateSyntaxError from django.template.loader import render_to_string from django.urls import path, resolve @@ -18,6 +19,9 @@ class DebugToolbar: + # for internal testing use only + _created = Signal() + def __init__(self, request, get_response): self.request = request self.config = dt_settings.get_config().copy() @@ -28,6 +32,9 @@ def __init__(self, request, get_response): if panel.enabled: get_response = panel.process_request self.process_request = get_response + # Use OrderedDict for the _panels attribute so that items can be efficiently + # removed using FIFO order in the DebugToolbar.store() method. The .popitem() + # method of Python's built-in dict only supports LIFO removal. self._panels = OrderedDict() while panels: panel = panels.pop() @@ -35,6 +42,7 @@ def __init__(self, request, get_response): self.stats = {} self.server_timing_stats = {} self.store_id = None + self._created.send(request, toolbar=self) # Manage panels diff --git a/debug_toolbar/utils.py b/debug_toolbar/utils.py index 6b80c5af0..bd74e6eed 100644 --- a/debug_toolbar/utils.py +++ b/debug_toolbar/utils.py @@ -1,12 +1,11 @@ import inspect +import linecache import os.path -import re import sys -from importlib import import_module +import warnings from pprint import pformat -import django -from django.core.exceptions import ImproperlyConfigured +from asgiref.local import Local from django.template import Node from django.utils.html import format_html from django.utils.safestring import mark_safe @@ -19,44 +18,45 @@ threading = None -# Figure out some paths -django_path = os.path.realpath(os.path.dirname(django.__file__)) +_local_data = Local() -def get_module_path(module_name): - try: - module = import_module(module_name) - except ImportError as e: - raise ImproperlyConfigured(f"Error importing HIDE_IN_STACKTRACES: {e}") - else: - source_path = inspect.getsourcefile(module) - if source_path.endswith("__init__.py"): - source_path = os.path.dirname(source_path) - return os.path.realpath(source_path) - +def _is_excluded_frame(frame, excluded_modules): + if not excluded_modules: + return False + frame_module = frame.f_globals.get("__name__") + if not isinstance(frame_module, str): + return False + return any( + frame_module == excluded_module + or frame_module.startswith(excluded_module + ".") + for excluded_module in excluded_modules + ) -hidden_paths = [ - get_module_path(module_name) - for module_name in dt_settings.get_config()["HIDE_IN_STACKTRACES"] -] - -def omit_path(path): - return any(path.startswith(hidden_path) for hidden_path in hidden_paths) +def _stack_trace_deprecation_warning(): + warnings.warn( + "get_stack() and tidy_stacktrace() are deprecated in favor of" + " get_stack_trace()", + DeprecationWarning, + stacklevel=2, + ) def tidy_stacktrace(stack): """ - Clean up stacktrace and remove all entries that: - 1. Are part of Django (except contrib apps) - 2. Are part of socketserver (used by Django's dev server) - 3. Are the last entry (which is part of our stacktracing code) + Clean up stacktrace and remove all entries that are excluded by the + HIDE_IN_STACKTRACES setting. - ``stack`` should be a list of frame tuples from ``inspect.stack()`` + ``stack`` should be a list of frame tuples from ``inspect.stack()`` or + ``debug_toolbar.utils.get_stack()``. """ + _stack_trace_deprecation_warning() + trace = [] + excluded_modules = dt_settings.get_config()["HIDE_IN_STACKTRACES"] for frame, path, line_no, func_name, text in (f[:5] for f in stack): - if omit_path(os.path.realpath(path)): + if _is_excluded_frame(frame, excluded_modules): continue text = "".join(text).strip() if text else "" frame_locals = ( @@ -201,42 +201,30 @@ def getframeinfo(frame, context=1): try: lines, lnum = inspect.findsource(frame) except Exception: # findsource raises platform-dependant exceptions - first_lines = lines = index = None + lines = index = None else: start = max(start, 1) start = max(0, min(start, len(lines) - context)) - first_lines = lines[:2] lines = lines[start : (start + context)] index = lineno - 1 - start else: - first_lines = lines = index = None - - # Code taken from Django's ExceptionReporter._get_lines_from_file - if first_lines and isinstance(first_lines[0], bytes): - encoding = "ascii" - for line in first_lines[:2]: - # File coding may be specified. Match pattern from PEP-263 - # (https://www.python.org/dev/peps/pep-0263/) - match = re.search(rb"coding[:=]\s*([-\w.]+)", line) - if match: - encoding = match.group(1).decode("ascii") - break - lines = [line.decode(encoding, "replace") for line in lines] + lines = index = None - if hasattr(inspect, "Traceback"): - return inspect.Traceback(filename, lineno, frame.f_code.co_name, lines, index) - else: - return (filename, lineno, frame.f_code.co_name, lines, index) + return inspect.Traceback(filename, lineno, frame.f_code.co_name, lines, index) def get_sorted_request_variable(variable): """ - Get a sorted list of variables from the request data. + Get a data structure for showing a sorted list of variables from the + request data. """ - if isinstance(variable, dict): - return [(k, variable.get(k)) for k in sorted(variable)] - else: - return [(k, variable.getlist(k)) for k in sorted(variable)] + try: + if isinstance(variable, dict): + return {"list": [(k, variable.get(k)) for k in sorted(variable)]} + else: + return {"list": [(k, variable.getlist(k)) for k in sorted(variable)]} + except TypeError: + return {"raw": variable} def get_stack(context=1): @@ -248,6 +236,8 @@ def get_stack(context=1): Modified version of ``inspect.stack()`` which calls our own ``getframeinfo()`` """ + _stack_trace_deprecation_warning() + frame = sys._getframe(1) framelist = [] while frame: @@ -256,6 +246,103 @@ def get_stack(context=1): return framelist +def _stack_frames(*, skip=0): + skip += 1 # Skip the frame for this generator. + frame = inspect.currentframe() + while frame is not None: + if skip > 0: + skip -= 1 + else: + yield frame + frame = frame.f_back + + +class _StackTraceRecorder: + def __init__(self): + self.filename_cache = {} + + def get_source_file(self, frame): + frame_filename = frame.f_code.co_filename + + value = self.filename_cache.get(frame_filename) + if value is None: + filename = inspect.getsourcefile(frame) + if filename is None: + is_source = False + filename = frame_filename + else: + is_source = True + # Ensure linecache validity the first time this recorder + # encounters the filename in this frame. + linecache.checkcache(filename) + value = (filename, is_source) + self.filename_cache[frame_filename] = value + + return value + + def get_stack_trace(self, *, excluded_modules=None, include_locals=False, skip=0): + trace = [] + skip += 1 # Skip the frame for this method. + for frame in _stack_frames(skip=skip): + if _is_excluded_frame(frame, excluded_modules): + continue + + filename, is_source = self.get_source_file(frame) + + line_no = frame.f_lineno + func_name = frame.f_code.co_name + + if is_source: + module = inspect.getmodule(frame, filename) + module_globals = module.__dict__ if module is not None else None + source_line = linecache.getline( + filename, line_no, module_globals + ).strip() + else: + source_line = "" + + frame_locals = frame.f_locals if include_locals else None + + trace.append((filename, line_no, func_name, source_line, frame_locals)) + trace.reverse() + return trace + + +def get_stack_trace(*, skip=0): + """ + Return a processed stack trace for the current call stack. + + If the ``ENABLE_STACKTRACES`` setting is False, return an empty :class:`list`. + Otherwise return a :class:`list` of processed stack frame tuples (file name, line + number, function name, source line, frame locals) for the current call stack. The + first entry in the list will be for the bottom of the stack and the last entry will + be for the top of the stack. + + ``skip`` is an :class:`int` indicating the number of stack frames above the frame + for this function to omit from the stack trace. The default value of ``0`` means + that the entry for the caller of this function will be the last entry in the + returned stack trace. + """ + config = dt_settings.get_config() + if not config["ENABLE_STACKTRACES"]: + return [] + skip += 1 # Skip the frame for this function. + stack_trace_recorder = getattr(_local_data, "stack_trace_recorder", None) + if stack_trace_recorder is None: + stack_trace_recorder = _StackTraceRecorder() + _local_data.stack_trace_recorder = stack_trace_recorder + return stack_trace_recorder.get_stack_trace( + excluded_modules=config["HIDE_IN_STACKTRACES"], + include_locals=config["ENABLE_STACKTRACES_LOCALS"], + skip=skip, + ) + + +def clear_stack_trace_caches(): + if hasattr(_local_data, "stack_trace_recorder"): + del _local_data.stack_trace_recorder + + class ThreadCollector: def __init__(self): if threading is None: diff --git a/docs/changes.rst b/docs/changes.rst index bad3bc033..25ef409fc 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -1,6 +1,45 @@ Change log ========== +3.5.0 (2022-06-23) +------------------ + +* Properly implemented tracking and display of PostgreSQL transactions. +* Removed third party panels which have been archived on GitHub. +* Added Django 4.1b1 to the CI matrix. +* Stopped crashing when ``request.GET`` and ``request.POST`` are neither + dictionaries nor ``QueryDict`` instances. Using anything but ``QueryDict`` + instances isn't a valid use of Django but, again, django-debug-toolbar + shouldn't crash. +* Fixed the cache panel to work correctly in the presence of concurrency by + avoiding the use of signals. +* Reworked the cache panel instrumentation mechanism to monkey patch methods on + the cache instances directly instead of replacing cache instances with + wrapper classes. +* Added a :meth:`debug_toolbar.panels.Panel.ready` class method that panels can + override to perform any initialization or instrumentation that needs to be + done unconditionally at startup time. +* Added pyflame (for flame graphs) to the list of third-party panels. +* Fixed the cache panel to correctly count cache misses from the get_many() + cache method. +* Removed some obsolete compatibility code from the stack trace recording code. +* Added a new mechanism for capturing stack traces which includes per-request + caching to reduce expensive file system operations. Updated the cache and + SQL panels to record stack traces using this new mechanism. +* Changed the ``docs`` tox environment to allow passing positional arguments. + This allows e.g. building a HTML version of the docs using ``tox -e docs + html``. +* Stayed on top of pre-commit hook updates. +* Replaced ``OrderedDict`` by ``dict`` where possible. + +Deprecated features +~~~~~~~~~~~~~~~~~~~ + +* The ``debug_toolbar.utils.get_stack()`` and + ``debug_toolbar.utils.tidy_stacktrace()`` functions are deprecated in favor + of the new ``debug_toolbar.utils.get_stack_trace()`` function. They will + removed in the next major version of the Debug Toolbar. + 3.4.0 (2022-05-03) ------------------ diff --git a/docs/checks.rst b/docs/checks.rst index 1140d6b49..b76f761a0 100644 --- a/docs/checks.rst +++ b/docs/checks.rst @@ -2,8 +2,8 @@ System checks ============= -The following :doc:`system checks ` help verify the Django -Debug Toolbar setup and configuration: +The following :external:doc:`system checks ` help verify the +Django Debug Toolbar setup and configuration: * **debug_toolbar.W001**: ``debug_toolbar.middleware.DebugToolbarMiddleware`` is missing from ``MIDDLEWARE``. diff --git a/docs/conf.py b/docs/conf.py index 6bf4770dc..374acd1d2 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 = "3.4.0" +release = "3.5.0" # -- General configuration --------------------------------------------------- diff --git a/docs/panels.rst b/docs/panels.rst index fc75763f7..8e5558aab 100644 --- a/docs/panels.rst +++ b/docs/panels.rst @@ -141,25 +141,17 @@ Third-party panels If you'd like to add a panel to this list, please submit a pull request! -Flamegraph -~~~~~~~~~~ - -URL: https://github.com/23andMe/djdt-flamegraph - -Path: ``djdt_flamegraph.FlamegraphPanel`` - -Generates a flame graph from your current request. - -Haystack -~~~~~~~~ - -URL: https://github.com/streeter/django-haystack-panel +Flame Graphs +~~~~~~~~~~~~ -Path: ``haystack_panel.panel.HaystackDebugPanel`` +URL: https://gitlab.com/living180/pyflame -See queries made by your Haystack_ backends. +Path: ``pyflame.djdt.panel.FlamegraphPanel`` -.. _Haystack: http://haystacksearch.org/ +Displays a flame graph for visualizing the performance profile of the request, +using Brendan Gregg's `flamegraph.pl script +`_ to perform the +heavy lifting. HTML Tidy/Validator ~~~~~~~~~~~~~~~~~~~ @@ -372,6 +364,8 @@ unauthorized access. There is no public CSS API at this time. .. autoattribute:: debug_toolbar.panels.Panel.scripts + .. automethod:: debug_toolbar.panels.Panel.ready + .. automethod:: debug_toolbar.panels.Panel.get_urls .. automethod:: debug_toolbar.panels.Panel.enable_instrumentation diff --git a/docs/spelling_wordlist.txt b/docs/spelling_wordlist.txt index 8eddeba4a..0ca7eb8b0 100644 --- a/docs/spelling_wordlist.txt +++ b/docs/spelling_wordlist.txt @@ -25,6 +25,7 @@ pre profiler psycopg py +pyflame pylibmc Pympler querysets @@ -33,6 +34,7 @@ spooler stacktrace stacktraces timeline +tox Transifex unhashable uWSGI diff --git a/setup.cfg b/setup.cfg index b984e23cc..2fe12e38d 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,6 +1,6 @@ [metadata] name = django-debug-toolbar -version = 3.4.0 +version = 3.5.0 description = A configurable set of panels that display various debug information about the current request/response. long_description = file: README.rst long_description_content_type = text/x-rst @@ -60,7 +60,7 @@ source = [coverage:report] # Update coverage badge link in README.rst when fail_under changes -fail_under = 89 +fail_under = 93 show_missing = True [flake8] diff --git a/tests/base.py b/tests/base.py index 597a74f29..5cc432add 100644 --- a/tests/base.py +++ b/tests/base.py @@ -1,13 +1,37 @@ import html5lib +from asgiref.local import Local from django.http import HttpResponse -from django.test import RequestFactory, TestCase +from django.test import Client, RequestFactory, TestCase, TransactionTestCase from debug_toolbar.toolbar import DebugToolbar + +class ToolbarTestClient(Client): + def request(self, **request): + # Use a thread/async task context-local variable to guard against a + # concurrent _created signal from a different thread/task. + data = Local() + data.toolbar = None + + def handle_toolbar_created(sender, toolbar=None, **kwargs): + data.toolbar = toolbar + + DebugToolbar._created.connect(handle_toolbar_created) + try: + response = super().request(**request) + finally: + DebugToolbar._created.disconnect(handle_toolbar_created) + response.toolbar = data.toolbar + + return response + + rf = RequestFactory() -class BaseTestCase(TestCase): +class BaseMixin: + client_class = ToolbarTestClient + panel_id = None def setUp(self): @@ -43,6 +67,14 @@ def assertValidHTML(self, content): raise self.failureException("\n".join(msg_parts)) +class BaseTestCase(BaseMixin, TestCase): + pass + + +class BaseMultiDBTestCase(BaseMixin, TransactionTestCase): + databases = {"default", "replica"} + + class IntegrationTestCase(TestCase): """Base TestCase for tests involving clients making requests.""" diff --git a/tests/panels/test_cache.py b/tests/panels/test_cache.py index d45eabb26..aacf521cb 100644 --- a/tests/panels/test_cache.py +++ b/tests/panels/test_cache.py @@ -26,6 +26,23 @@ def test_recording_caches(self): second_cache.get("foo") self.assertEqual(len(self.panel.calls), 2) + def test_hits_and_misses(self): + cache.cache.clear() + cache.cache.get("foo") + self.assertEqual(self.panel.hits, 0) + self.assertEqual(self.panel.misses, 1) + cache.cache.set("foo", 1) + cache.cache.get("foo") + self.assertEqual(self.panel.hits, 1) + self.assertEqual(self.panel.misses, 1) + cache.cache.get_many(["foo", "bar"]) + self.assertEqual(self.panel.hits, 2) + self.assertEqual(self.panel.misses, 2) + cache.cache.set("bar", 2) + cache.cache.get_many(keys=["foo", "bar"]) + self.assertEqual(self.panel.hits, 4) + self.assertEqual(self.panel.misses, 2) + def test_get_or_set_value(self): cache.cache.get_or_set("baz", "val") self.assertEqual(cache.cache.get("baz"), "val") diff --git a/tests/panels/test_request.py b/tests/panels/test_request.py index 1d2a33c56..8087203c3 100644 --- a/tests/panels/test_request.py +++ b/tests/panels/test_request.py @@ -85,6 +85,19 @@ def test_dict_for_request_in_method_post(self): self.assertIn("foo", content) self.assertIn("bar", content) + def test_list_for_request_in_method_post(self): + """ + Verify that the toolbar doesn't crash if request.POST contains unexpected data. + + See https://github.com/jazzband/django-debug-toolbar/issues/1621 + """ + self.request.POST = [{"a": 1}, {"b": 2}] + response = self.panel.process_request(self.request) + self.panel.generate_stats(self.request, response) + # ensure the panel POST request data is processed correctly. + content = self.panel.content + self.assertIn("[{'a': 1}, {'b': 2}]", content) + def test_namespaced_url(self): self.request.path = "/admin/login/" response = self.panel.process_request(self.request) diff --git a/tests/panels/test_sql.py b/tests/panels/test_sql.py index 9824a1bec..f078820c8 100644 --- a/tests/panels/test_sql.py +++ b/tests/panels/test_sql.py @@ -7,7 +7,7 @@ import django from asgiref.sync import sync_to_async from django.contrib.auth.models import User -from django.db import connection +from django.db import connection, transaction from django.db.models import Count from django.db.utils import DatabaseError from django.shortcuts import render @@ -16,7 +16,7 @@ import debug_toolbar.panels.sql.tracking as sql_tracking from debug_toolbar import settings as dt_settings -from ..base import BaseTestCase +from ..base import BaseMultiDBTestCase, BaseTestCase from ..models import PostgresJSON @@ -44,13 +44,13 @@ def test_recording(self): # ensure query was logged self.assertEqual(len(self.panel._queries), 1) query = self.panel._queries[0] - self.assertEqual(query[0], "default") - self.assertTrue("sql" in query[1]) - self.assertTrue("duration" in query[1]) - self.assertTrue("stacktrace" in query[1]) + self.assertEqual(query["alias"], "default") + self.assertTrue("sql" in query) + self.assertTrue("duration" in query) + self.assertTrue("stacktrace" in query) # ensure the stacktrace is populated - self.assertTrue(len(query[1]["stacktrace"]) > 0) + self.assertTrue(len(query["stacktrace"]) > 0) @unittest.skipUnless( connection.vendor == "postgresql", "Test valid only on PostgreSQL" @@ -128,7 +128,7 @@ def test_generate_server_timing(self): query = self.panel._queries[0] expected_data = { - "sql_time": {"title": "SQL 1 queries", "value": query[1]["duration"]} + "sql_time": {"title": "SQL 1 queries", "value": query["duration"]} } self.assertEqual(self.panel.get_server_timing_stats(), expected_data) @@ -195,7 +195,7 @@ def test_param_conversion(self): expected_datetime = '["2017-12-22 16:07:01"]' self.assertEqual( - tuple(q[1]["params"] for q in self.panel._queries), + tuple(query["params"] for query in self.panel._queries), ( expected_bools, "[10, 1]", @@ -217,7 +217,7 @@ def test_json_param_conversion(self): # ensure query was logged self.assertEqual(len(self.panel._queries), 1) self.assertEqual( - self.panel._queries[0][1]["params"], + self.panel._queries[0]["params"], '["{\\"foo\\": \\"bar\\"}"]', ) @@ -237,7 +237,7 @@ def test_binary_param_force_text(self): self.assertIn( "SELECT * FROM" " tests_binary WHERE field =", - self.panel._queries[0][1]["sql"], + self.panel._queries[0]["sql"], ) @unittest.skipUnless(connection.vendor != "sqlite", "Test invalid for SQLite") @@ -288,7 +288,7 @@ def test_raw_query_param_conversion(self): self.assertEqual(len(self.panel._queries), 2) self.assertEqual( - tuple(q[1]["params"] for q in self.panel._queries), + tuple(query["params"] for query in self.panel._queries), ( '["Foo", true, false, "2017-12-22 16:07:01"]', " ".join( @@ -375,9 +375,9 @@ def test_execute_with_psycopg2_composed_sql(self): self.assertEqual(len(self.panel._queries), 1) query = self.panel._queries[0] - self.assertEqual(query[0], "default") - self.assertTrue("sql" in query[1]) - self.assertEqual(query[1]["sql"], 'select "username" from "auth_user"') + self.assertEqual(query["alias"], "default") + self.assertTrue("sql" in query) + self.assertEqual(query["sql"], 'select "username" from "auth_user"') def test_disable_stacktraces(self): self.assertEqual(len(self.panel._queries), 0) @@ -388,13 +388,13 @@ def test_disable_stacktraces(self): # ensure query was logged self.assertEqual(len(self.panel._queries), 1) query = self.panel._queries[0] - self.assertEqual(query[0], "default") - self.assertTrue("sql" in query[1]) - self.assertTrue("duration" in query[1]) - self.assertTrue("stacktrace" in query[1]) + self.assertEqual(query["alias"], "default") + self.assertTrue("sql" in query) + self.assertTrue("duration" in query) + self.assertTrue("stacktrace" in query) # ensure the stacktrace is empty - self.assertEqual([], query[1]["stacktrace"]) + self.assertEqual([], query["stacktrace"]) @override_settings( DEBUG=True, @@ -418,13 +418,13 @@ def test_regression_infinite_recursion(self): # template is loaded and basic.html extends base.html. self.assertEqual(len(self.panel._queries), 2) query = self.panel._queries[0] - self.assertEqual(query[0], "default") - self.assertTrue("sql" in query[1]) - self.assertTrue("duration" in query[1]) - self.assertTrue("stacktrace" in query[1]) + self.assertEqual(query["alias"], "default") + self.assertTrue("sql" in query) + self.assertTrue("duration" in query) + self.assertTrue("stacktrace" in query) # ensure the stacktrace is populated - self.assertTrue(len(query[1]["stacktrace"]) > 0) + self.assertTrue(len(query["stacktrace"]) > 0) @override_settings( DEBUG_TOOLBAR_CONFIG={"PRETTIFY_SQL": True}, @@ -439,7 +439,7 @@ def test_prettify_sql(self): response = self.panel.process_request(self.request) self.panel.generate_stats(self.request, response) - pretty_sql = self.panel._queries[-1][1]["sql"] + pretty_sql = self.panel._queries[-1]["sql"] self.assertEqual(len(self.panel._queries), 1) # Reset the queries @@ -450,7 +450,7 @@ def test_prettify_sql(self): 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][1]["sql"]) + self.assertNotEqual(pretty_sql, self.panel._queries[-1]["sql"]) self.panel._queries = [] # Run it again, but with prettyify back on. @@ -461,7 +461,7 @@ def test_prettify_sql(self): 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][1]["sql"]) + self.assertEqual(pretty_sql, self.panel._queries[-1]["sql"]) @override_settings( DEBUG=True, @@ -479,7 +479,7 @@ def test_flat_template_information(self): self.assertEqual(len(self.panel._queries), 1) query = self.panel._queries[0] - template_info = query[1]["template_info"] + template_info = query["template_info"] template_name = os.path.basename(template_info["name"]) self.assertEqual(template_name, "flat.html") self.assertEqual(template_info["context"][2]["content"].strip(), "{{ users }}") @@ -501,8 +501,160 @@ def test_nested_template_information(self): self.assertEqual(len(self.panel._queries), 1) query = self.panel._queries[0] - template_info = query[1]["template_info"] + template_info = query["template_info"] template_name = os.path.basename(template_info["name"]) self.assertEqual(template_name, "included.html") self.assertEqual(template_info["context"][0]["content"].strip(), "{{ users }}") self.assertEqual(template_info["context"][0]["highlight"], True) + + def test_similar_and_duplicate_grouping(self): + self.assertEqual(len(self.panel._queries), 0) + + User.objects.filter(id=1).count() + User.objects.filter(id=1).count() + User.objects.filter(id=2).count() + User.objects.filter(id__lt=10).count() + User.objects.filter(id__lt=20).count() + User.objects.filter(id__gt=10, id__lt=20).count() + + response = self.panel.process_request(self.request) + self.panel.generate_stats(self.request, response) + + self.assertEqual(len(self.panel._queries), 6) + + queries = self.panel._queries + query = queries[0] + self.assertEqual(query["similar_count"], 3) + self.assertEqual(query["duplicate_count"], 2) + + query = queries[1] + self.assertEqual(query["similar_count"], 3) + self.assertEqual(query["duplicate_count"], 2) + + query = queries[2] + self.assertEqual(query["similar_count"], 3) + self.assertTrue("duplicate_count" not in query) + + query = queries[3] + self.assertEqual(query["similar_count"], 2) + self.assertTrue("duplicate_count" not in query) + + query = queries[4] + self.assertEqual(query["similar_count"], 2) + self.assertTrue("duplicate_count" not in query) + + query = queries[5] + self.assertTrue("similar_count" not in query) + self.assertTrue("duplicate_count" not in query) + + self.assertEqual(queries[0]["similar_color"], queries[1]["similar_color"]) + self.assertEqual(queries[0]["similar_color"], queries[2]["similar_color"]) + self.assertEqual(queries[0]["duplicate_color"], queries[1]["duplicate_color"]) + self.assertNotEqual(queries[0]["similar_color"], queries[0]["duplicate_color"]) + + self.assertEqual(queries[3]["similar_color"], queries[4]["similar_color"]) + self.assertNotEqual(queries[0]["similar_color"], queries[3]["similar_color"]) + self.assertNotEqual(queries[0]["duplicate_color"], queries[3]["similar_color"]) + + +class SQLPanelMultiDBTestCase(BaseMultiDBTestCase): + panel_id = "SQLPanel" + + def test_aliases(self): + self.assertFalse(self.panel._queries) + + list(User.objects.all()) + list(User.objects.using("replica").all()) + + response = self.panel.process_request(self.request) + self.panel.generate_stats(self.request, response) + + self.assertTrue(self.panel._queries) + + query = self.panel._queries[0] + self.assertEqual(query["alias"], "default") + + query = self.panel._queries[-1] + self.assertEqual(query["alias"], "replica") + + def test_transaction_status(self): + """ + Test case for tracking the transaction status is properly associated with + queries on PostgreSQL, and that transactions aren't broken on other database + engines. + """ + self.assertEqual(len(self.panel._queries), 0) + + with transaction.atomic(): + list(User.objects.all()) + list(User.objects.using("replica").all()) + + with transaction.atomic(using="replica"): + list(User.objects.all()) + list(User.objects.using("replica").all()) + + with transaction.atomic(): + list(User.objects.all()) + + list(User.objects.using("replica").all()) + + response = self.panel.process_request(self.request) + self.panel.generate_stats(self.request, response) + + if connection.vendor == "postgresql": + # Connection tracking is currently only implemented for PostgreSQL. + self.assertEqual(len(self.panel._queries), 6) + + query = self.panel._queries[0] + self.assertEqual(query["alias"], "default") + self.assertIsNotNone(query["trans_id"]) + self.assertTrue(query["starts_trans"]) + self.assertTrue(query["in_trans"]) + self.assertFalse("end_trans" in query) + + query = self.panel._queries[-1] + self.assertEqual(query["alias"], "replica") + self.assertIsNone(query["trans_id"]) + self.assertFalse("starts_trans" in query) + self.assertFalse("in_trans" in query) + self.assertFalse("end_trans" in query) + + query = self.panel._queries[2] + self.assertEqual(query["alias"], "default") + self.assertIsNotNone(query["trans_id"]) + self.assertEqual(query["trans_id"], self.panel._queries[0]["trans_id"]) + self.assertFalse("starts_trans" in query) + self.assertTrue(query["in_trans"]) + self.assertTrue(query["ends_trans"]) + + query = self.panel._queries[3] + self.assertEqual(query["alias"], "replica") + self.assertIsNotNone(query["trans_id"]) + self.assertNotEqual(query["trans_id"], self.panel._queries[0]["trans_id"]) + self.assertTrue(query["starts_trans"]) + self.assertTrue(query["in_trans"]) + self.assertTrue(query["ends_trans"]) + + query = self.panel._queries[4] + self.assertEqual(query["alias"], "default") + self.assertIsNotNone(query["trans_id"]) + self.assertNotEqual(query["trans_id"], self.panel._queries[0]["trans_id"]) + self.assertNotEqual(query["trans_id"], self.panel._queries[3]["trans_id"]) + self.assertTrue(query["starts_trans"]) + self.assertTrue(query["in_trans"]) + self.assertTrue(query["ends_trans"]) + + query = self.panel._queries[5] + self.assertEqual(query["alias"], "replica") + self.assertIsNone(query["trans_id"]) + self.assertFalse("starts_trans" in query) + self.assertFalse("in_trans" in query) + self.assertFalse("end_trans" in query) + else: + # Ensure that nothing was recorded for other database engines. + self.assertTrue(self.panel._queries) + for query in self.panel._queries: + self.assertFalse("trans_id" in query) + self.assertFalse("starts_trans" in query) + self.assertFalse("in_trans" in query) + self.assertFalse("end_trans" in query) diff --git a/tests/settings.py b/tests/settings.py index da5067fbf..b3c281242 100644 --- a/tests/settings.py +++ b/tests/settings.py @@ -104,6 +104,20 @@ "USER": "default_test", }, }, + "replica": { + "ENGINE": "django.{}db.backends.{}".format( + "contrib.gis." if USE_GIS else "", os.getenv("DB_BACKEND", "sqlite3") + ), + "NAME": os.getenv("DB_NAME", ":memory:"), + "USER": os.getenv("DB_USER"), + "PASSWORD": os.getenv("DB_PASSWORD"), + "HOST": os.getenv("DB_HOST", ""), + "PORT": os.getenv("DB_PORT", ""), + "TEST": { + "USER": "default_test", + "MIRROR": "default", + }, + }, } DEFAULT_AUTO_FIELD = "django.db.models.AutoField" diff --git a/tests/test_forms.py b/tests/test_forms.py index da144e108..a619ae89d 100644 --- a/tests/test_forms.py +++ b/tests/test_forms.py @@ -7,7 +7,7 @@ SIGNATURE = "-WiogJKyy4E8Om00CrFSy0T6XHObwBa6Zb46u-vmeYE" -DATA = {"value": "foo", "date": datetime(2020, 1, 1, tzinfo=timezone.utc)} +DATA = {"date": datetime(2020, 1, 1, tzinfo=timezone.utc), "value": "foo"} SIGNED_DATA = f'{{"date": "2020-01-01 00:00:00+00:00", "value": "foo"}}:{SIGNATURE}' diff --git a/tests/test_integration.py b/tests/test_integration.py index 702fa8141..016b52217 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -109,10 +109,10 @@ def test_cache_page(self): # Clear the cache before testing the views. Other tests that use cached_view # may run earlier and cause fewer cache calls. cache.clear() - self.client.get("/cached_view/") - self.assertEqual(len(self.toolbar.get_panel_by_id("CachePanel").calls), 3) - self.client.get("/cached_view/") - self.assertEqual(len(self.toolbar.get_panel_by_id("CachePanel").calls), 5) + response = self.client.get("/cached_view/") + self.assertEqual(len(response.toolbar.get_panel_by_id("CachePanel").calls), 3) + response = self.client.get("/cached_view/") + self.assertEqual(len(response.toolbar.get_panel_by_id("CachePanel").calls), 2) @override_settings(ROOT_URLCONF="tests.urls_use_package_urls") def test_include_package_urls(self): @@ -120,17 +120,17 @@ def test_include_package_urls(self): # Clear the cache before testing the views. Other tests that use cached_view # may run earlier and cause fewer cache calls. cache.clear() - self.client.get("/cached_view/") - self.assertEqual(len(self.toolbar.get_panel_by_id("CachePanel").calls), 3) - self.client.get("/cached_view/") - self.assertEqual(len(self.toolbar.get_panel_by_id("CachePanel").calls), 5) + response = self.client.get("/cached_view/") + self.assertEqual(len(response.toolbar.get_panel_by_id("CachePanel").calls), 3) + response = self.client.get("/cached_view/") + self.assertEqual(len(response.toolbar.get_panel_by_id("CachePanel").calls), 2) def test_low_level_cache_view(self): """Test cases when low level caching API is used within a request.""" - self.client.get("/cached_low_level_view/") - self.assertEqual(len(self.toolbar.get_panel_by_id("CachePanel").calls), 2) - self.client.get("/cached_low_level_view/") - self.assertEqual(len(self.toolbar.get_panel_by_id("CachePanel").calls), 3) + response = self.client.get("/cached_low_level_view/") + self.assertEqual(len(response.toolbar.get_panel_by_id("CachePanel").calls), 2) + response = self.client.get("/cached_low_level_view/") + self.assertEqual(len(response.toolbar.get_panel_by_id("CachePanel").calls), 1) def test_cache_disable_instrumentation(self): """ @@ -139,10 +139,10 @@ def test_cache_disable_instrumentation(self): """ self.assertIsNone(cache.set("UseCacheAfterToolbar.before", None)) self.assertIsNone(cache.set("UseCacheAfterToolbar.after", None)) - self.client.get("/execute_sql/") + response = self.client.get("/execute_sql/") self.assertEqual(cache.get("UseCacheAfterToolbar.before"), 1) self.assertEqual(cache.get("UseCacheAfterToolbar.after"), 1) - self.assertEqual(len(self.toolbar.get_panel_by_id("CachePanel").calls), 0) + self.assertEqual(len(response.toolbar.get_panel_by_id("CachePanel").calls), 0) def test_is_toolbar_request(self): self.request.path = "/__debug__/render_panel/" diff --git a/tests/test_utils.py b/tests/test_utils.py index 9cfc33bc7..31a67a6c1 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,6 +1,15 @@ import unittest -from debug_toolbar.utils import get_name_from_obj, render_stacktrace +from django.test import override_settings + +import debug_toolbar.utils +from debug_toolbar.utils import ( + get_name_from_obj, + get_stack, + get_stack_trace, + render_stacktrace, + tidy_stacktrace, +) class GetNameFromObjTestCase(unittest.TestCase): @@ -47,3 +56,27 @@ def test_importlib_path_issue_1612(self): '<frozen importlib._bootstrap> in', result, ) + + +class StackTraceTestCase(unittest.TestCase): + @override_settings(DEBUG_TOOLBAR_CONFIG={"HIDE_IN_STACKTRACES": []}) + def test_get_stack_trace_skip(self): + stack_trace = get_stack_trace(skip=-1) + self.assertTrue(len(stack_trace) > 2) + self.assertEqual(stack_trace[-1][0], debug_toolbar.utils.__file__) + self.assertEqual(stack_trace[-1][2], "get_stack_trace") + self.assertEqual(stack_trace[-2][0], __file__) + self.assertEqual(stack_trace[-2][2], "test_get_stack_trace_skip") + + stack_trace = get_stack_trace() + self.assertTrue(len(stack_trace) > 1) + self.assertEqual(stack_trace[-1][0], __file__) + self.assertEqual(stack_trace[-1][2], "test_get_stack_trace_skip") + + def test_deprecated_functions(self): + with self.assertWarns(DeprecationWarning): + stack = get_stack() + self.assertEqual(stack[0][1], __file__) + with self.assertWarns(DeprecationWarning): + stack_trace = tidy_stacktrace(reversed(stack)) + self.assertEqual(stack_trace[-1][0], __file__) diff --git a/tox.ini b/tox.ini index c2f5ad8ee..5485a2742 100644 --- a/tox.ini +++ b/tox.ini @@ -3,12 +3,13 @@ envlist = docs packaging py{37}-dj{32}-{sqlite,postgresql,postgis,mysql} - py{38,39,310}-dj{32,40,main}-{sqlite,postgresql,postgis,mysql} + py{38,39,310}-dj{32,40,41,main}-{sqlite,postgresql,postgis,mysql} [testenv] deps = dj32: django~=3.2.9 dj40: django~=4.0.0 + dj41: django>=4.1b1,<4.2 postgresql: psycopg2-binary postgis: psycopg2-binary mysql: mysqlclient @@ -41,32 +42,32 @@ whitelist_externals = make pip_pre = True commands = python -b -W always -m coverage run -m django test -v2 {posargs:tests} -[testenv:py{37,38,39,310}-dj{40,main}-postgresql] +[testenv:py{37,38,39,310}-dj{32,40,41,main}-postgresql] setenv = {[testenv]setenv} DB_BACKEND = postgresql DB_PORT = {env:DB_PORT:5432} -[testenv:py{37,38,39,310}-dj{32,40,main}-postgis] +[testenv:py{37,38,39,310}-dj{32,40,41,main}-postgis] setenv = {[testenv]setenv} DB_BACKEND = postgis DB_PORT = {env:DB_PORT:5432} -[testenv:py{37,38,39,310}-dj{32,40,main}-mysql] +[testenv:py{37,38,39,310}-dj{32,40,41,main}-mysql] setenv = {[testenv]setenv} DB_BACKEND = mysql DB_PORT = {env:DB_PORT:3306} -[testenv:py{37,38,39,310}-dj{32,40,main}-sqlite] +[testenv:py{37,38,39,310}-dj{32,40,41,main}-sqlite] setenv = {[testenv]setenv} DB_BACKEND = sqlite3 DB_NAME = ":memory:" [testenv:docs] -commands = make -C {toxinidir}/docs spelling +commands = make -C {toxinidir}/docs {posargs:spelling} deps = Sphinx sphinxcontrib-spelling