diff --git a/.eslintrc.json b/.eslintrc.json deleted file mode 100644 index 8a2452b7a..000000000 --- a/.eslintrc.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "env": { - "browser": true, - "es6": true - }, - "extends": "eslint:recommended", - "parserOptions": { - "ecmaVersion": 6, - "sourceType": "module" - }, - "rules": { - "curly": ["error", "all"], - "dot-notation": "error", - "eqeqeq": "error", - "no-eval": "error", - "no-var": "error", - "prefer-const": "error", - "semi": "error" - } -} diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000..be006de9a --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,13 @@ +# Keep GitHub Actions up to date with GitHub's Dependabot... +# https://docs.github.com/en/code-security/dependabot/working-with-dependabot/keeping-your-actions-up-to-date-with-dependabot +# https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file#package-ecosystem +version: 2 +updates: + - package-ecosystem: github-actions + directory: / + groups: + github-actions: + patterns: + - "*" # Group all Actions updates into a single larger pull request + schedule: + interval: weekly diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml new file mode 100644 index 000000000..a0722f0ac --- /dev/null +++ b/.github/workflows/coverage.yml @@ -0,0 +1,33 @@ +# .github/workflows/coverage.yml +name: Post coverage comment + +on: + workflow_run: + workflows: ["Test"] + types: + - completed + +jobs: + test: + name: Run tests & display coverage + runs-on: ubuntu-latest + if: github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success' + permissions: + # Gives the action the necessary permissions for publishing new + # comments in pull requests. + pull-requests: write + # Gives the action the necessary permissions for editing existing + # comments (to avoid publishing multiple comments in the same PR) + contents: write + # Gives the action the necessary permissions for looking up the + # workflow that launched this workflow, and download the related + # artifact that contains the comment to be published + actions: read + steps: + # DO NOT run actions/checkout here, for security reasons + # For details, refer to https://securitylab.github.com/research/github-actions-preventing-pwn-requests/ + - name: Post comment + uses: py-cov-action/python-coverage-comment-action@v3 + with: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_PR_RUN_ID: ${{ github.event.workflow_run.id }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c3cb00937..b57181444 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -11,12 +11,12 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 with: fetch-depth: 0 - name: Set up Python - uses: actions/setup-python@v2 + uses: actions/setup-python@v5 with: python-version: 3.8 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 9dece68ef..cd5d8dd8b 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -14,7 +14,7 @@ jobs: fail-fast: false max-parallel: 5 matrix: - python-version: ['3.8', '3.9', '3.10', '3.11'] + python-version: ['3.8', '3.9', '3.10', '3.11', '3.12'] services: mariadb: @@ -30,12 +30,13 @@ jobs: - 3306:3306 steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} + allow-prereleases: true - name: Get pip cache dir id: pip-cache @@ -43,7 +44,7 @@ jobs: echo "dir=$(pip cache dir)" >> $GITHUB_OUTPUT - name: Cache - uses: actions/cache@v3 + uses: actions/cache@v4 with: path: ${{ steps.pip-cache.outputs.dir }} key: @@ -65,11 +66,6 @@ jobs: DB_HOST: 127.0.0.1 DB_PORT: 3306 - - name: Upload coverage data - uses: actions/upload-artifact@v3 - with: - name: coverage-data - path: ".coverage.*" postgres: runs-on: ubuntu-latest @@ -77,14 +73,16 @@ jobs: fail-fast: false max-parallel: 5 matrix: - python-version: ['3.8', '3.9', '3.10', '3.11'] + python-version: ['3.8', '3.9', '3.10', '3.11', '3.12'] database: [postgresql, postgis] - # Add psycopg3 to our matrix for 3.10 and 3.11 + # Add psycopg3 to our matrix for modern python versions include: - python-version: '3.10' database: psycopg3 - python-version: '3.11' database: psycopg3 + - python-version: '3.12' + database: psycopg3 services: postgres: @@ -102,12 +100,13 @@ jobs: --health-retries 5 steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} + allow-prereleases: true - name: Get pip cache dir id: pip-cache @@ -115,7 +114,7 @@ jobs: echo "dir=$(pip cache dir)" >> $GITHUB_OUTPUT - name: Cache - uses: actions/cache@v3 + uses: actions/cache@v4 with: path: ${{ steps.pip-cache.outputs.dir }} key: @@ -140,27 +139,22 @@ jobs: DB_HOST: localhost DB_PORT: 5432 - - name: Upload coverage data - uses: actions/upload-artifact@v3 - with: - name: coverage-data - path: ".coverage.*" - sqlite: runs-on: ubuntu-latest strategy: fail-fast: false max-parallel: 5 matrix: - python-version: ['3.8', '3.9', '3.10', '3.11'] + python-version: ['3.8', '3.9', '3.10', '3.11', '3.12'] steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} + allow-prereleases: true - name: Get pip cache dir id: pip-cache @@ -168,7 +162,7 @@ jobs: echo "dir=$(pip cache dir)" >> $GITHUB_OUTPUT - name: Cache - uses: actions/cache@v3 + uses: actions/cache@v4 with: path: ${{ steps.pip-cache.outputs.dir }} key: @@ -187,53 +181,16 @@ jobs: DB_BACKEND: sqlite3 DB_NAME: ":memory:" - - name: Upload coverage data - uses: actions/upload-artifact@v3 - with: - name: coverage-data - path: ".coverage.*" - - coverage: - name: Check coverage. - runs-on: "ubuntu-latest" - needs: [sqlite, mysql, postgres] - steps: - - uses: actions/checkout@v3 - - uses: actions/setup-python@v4 - with: - # Use latest, so it understands all syntax. - python-version: "3.11" - - - run: python -m pip install --upgrade coverage[toml] - - - name: Download coverage data. - uses: actions/download-artifact@v3 - with: - name: coverage-data - - - name: Combine coverage & check percentage - run: | - python -m coverage combine - python -m coverage html - python -m coverage report - - - name: Upload HTML report if check failed. - uses: actions/upload-artifact@v3 - with: - name: html-report - path: htmlcov - if: ${{ failure() }} - lint: runs-on: ubuntu-latest strategy: fail-fast: false steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: 3.8 @@ -243,7 +200,7 @@ jobs: echo "dir=$(pip cache dir)" >> $GITHUB_OUTPUT - name: Cache - uses: actions/cache@v3 + uses: actions/cache@v4 with: path: ${{ steps.pip-cache.outputs.dir }} key: diff --git a/.gitignore b/.gitignore index ee3559cc4..401705437 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,6 @@ htmlcov .tox geckodriver.log coverage.xml +venv +.direnv/ +.envrc diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 57772e4f4..291fc94e9 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.4.0 + rev: v4.6.0 hooks: - id: check-toml - id: check-yaml @@ -14,46 +14,46 @@ repos: hooks: - id: doc8 - repo: https://github.com/adamchainz/django-upgrade - rev: 1.14.0 + rev: 1.19.0 hooks: - id: django-upgrade - args: [--target-version, "3.2"] + args: [--target-version, "4.2"] - repo: https://github.com/pre-commit/pygrep-hooks rev: v1.10.0 hooks: - id: rst-backticks - id: rst-directive-colons - repo: https://github.com/pre-commit/mirrors-prettier - rev: v3.0.1 + rev: v4.0.0-alpha.8 hooks: - id: prettier + entry: env PRETTIER_LEGACY_CLI=1 prettier types_or: [javascript, css] args: - - --trailing-comma=es5 + - --trailing-comma=es5 - repo: https://github.com/pre-commit/mirrors-eslint - rev: v8.47.0 + rev: v9.6.0 hooks: - id: eslint + additional_dependencies: + - "eslint@v9.0.0-beta.1" + - "@eslint/js@v9.0.0-beta.1" + - "globals" files: \.js?$ types: [file] args: - --fix - repo: https://github.com/astral-sh/ruff-pre-commit - rev: 'v0.0.284' + rev: 'v0.5.1' hooks: - id: ruff args: [--fix, --exit-non-zero-on-fix] -- repo: https://github.com/psf/black - rev: 23.7.0 - hooks: - - id: black - language_version: python3 - entry: black --target-version=py38 + - id: ruff-format - repo: https://github.com/tox-dev/pyproject-fmt - rev: 0.13.1 + rev: 2.1.4 hooks: - id: pyproject-fmt - repo: https://github.com/abravalheri/validate-pyproject - rev: v0.13 + rev: v0.18 hooks: - id: validate-pyproject diff --git a/Makefile b/Makefile index 1600496e5..a8dc9c287 100644 --- a/Makefile +++ b/Makefile @@ -6,6 +6,15 @@ example: --noinput --username="$(USER)" --email="$(USER)@mailinator.com" python example/manage.py runserver +example_async: + python example/manage.py migrate --noinput + -DJANGO_SUPERUSER_PASSWORD=p python example/manage.py createsuperuser \ + --noinput --username="$(USER)" --email="$(USER)@mailinator.com" + daphne example.asgi:application + +example_test: + python example/manage.py test example + test: DJANGO_SETTINGS_MODULE=tests.settings \ python -m django test $${TEST_ARGS:-tests} diff --git a/README.rst b/README.rst index b10a9ad91..4e195a796 100644 --- a/README.rst +++ b/README.rst @@ -44,8 +44,11 @@ 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.1.0. It works on -Django ≥ 3.2.4. +The current stable version of the Debug Toolbar is 4.4.5. It works on +Django ≥ 4.2.0. + +The Debug Toolbar does not currently support `Django's asynchronous views +`_. Documentation, including installation and configuration instructions, is available at https://django-debug-toolbar.readthedocs.io/. diff --git a/debug_toolbar/__init__.py b/debug_toolbar/__init__.py index dbe08451f..a1a09f2a1 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.2.0" +VERSION = "4.4.5" # 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 0a10a4b08..a49875bac 100644 --- a/debug_toolbar/apps.py +++ b/debug_toolbar/apps.py @@ -3,12 +3,14 @@ from django.apps import AppConfig from django.conf import settings -from django.core.checks import Warning, register +from django.core.checks import Error, Warning, register from django.middleware.gzip import GZipMiddleware +from django.urls import NoReverseMatch, reverse from django.utils.module_loading import import_string from django.utils.translation import gettext_lazy as _ -from debug_toolbar import settings as dt_settings +from debug_toolbar import APP_NAME, settings as dt_settings +from debug_toolbar.settings import CONFIG_DEFAULTS class DebugToolbarConfig(AppConfig): @@ -177,7 +179,7 @@ def check_panels(app_configs, **kwargs): return errors -@register() +@register def js_mimetype_check(app_configs, **kwargs): """ Check that JavaScript files are resolving to the correct content type. @@ -206,3 +208,66 @@ def js_mimetype_check(app_configs, **kwargs): ) ] return [] + + +@register +def debug_toolbar_installed_when_running_tests_check(app_configs, **kwargs): + """ + Check that the toolbar is not being used when tests are running + """ + # Check if show toolbar callback has changed + show_toolbar_changed = ( + dt_settings.get_config()["SHOW_TOOLBAR_CALLBACK"] + != CONFIG_DEFAULTS["SHOW_TOOLBAR_CALLBACK"] + ) + try: + # Check if the toolbar's urls are installed + reverse(f"{APP_NAME}:render_panel") + toolbar_urls_installed = True + except NoReverseMatch: + toolbar_urls_installed = False + + # If the user is using the default SHOW_TOOLBAR_CALLBACK, + # then the middleware will respect the change to settings.DEBUG. + # However, if the user has changed the callback to: + # DEBUG_TOOLBAR_CONFIG = {"SHOW_TOOLBAR_CALLBACK": lambda request: DEBUG} + # where DEBUG is not settings.DEBUG, then it won't pick up that Django' + # test runner has changed the value for settings.DEBUG, and the middleware + # will inject the toolbar, while the URLs aren't configured leading to a + # NoReverseMatch error. + likely_error_setup = show_toolbar_changed and not toolbar_urls_installed + + if ( + not settings.DEBUG + and dt_settings.get_config()["IS_RUNNING_TESTS"] + and likely_error_setup + ): + return [ + Error( + "The Django Debug Toolbar can't be used with tests", + hint="Django changes the DEBUG setting to False when running " + "tests. By default the Django Debug Toolbar is installed because " + "DEBUG is set to True. For most cases, you need to avoid installing " + "the toolbar when running tests. If you feel this check is in error, " + "you can set `DEBUG_TOOLBAR_CONFIG['IS_RUNNING_TESTS'] = False` to " + "bypass this check.", + id="debug_toolbar.E001", + ) + ] + else: + return [] + + +@register +def check_settings(app_configs, **kwargs): + errors = [] + USER_CONFIG = getattr(settings, "DEBUG_TOOLBAR_CONFIG", {}) + if "OBSERVE_REQUEST_CALLBACK" in USER_CONFIG: + errors.append( + Warning( + "The deprecated OBSERVE_REQUEST_CALLBACK setting is present in DEBUG_TOOLBAR_CONFIG.", + hint="Use the UPDATE_ON_FETCH and/or SHOW_TOOLBAR_CALLBACK settings instead.", + id="debug_toolbar.W008", + ) + ) + return errors diff --git a/debug_toolbar/middleware.py b/debug_toolbar/middleware.py index b5e5d0827..b089d1484 100644 --- a/debug_toolbar/middleware.py +++ b/debug_toolbar/middleware.py @@ -3,6 +3,7 @@ """ import re +import socket from functools import lru_cache from django.conf import settings @@ -10,16 +11,38 @@ 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") +from debug_toolbar.utils import clear_stack_trace_caches, is_processable_html_response def show_toolbar(request): """ Default function to determine whether to show the toolbar on a given page. """ - return settings.DEBUG and request.META.get("REMOTE_ADDR") in settings.INTERNAL_IPS + if not settings.DEBUG: + return False + + # Test: settings + if request.META.get("REMOTE_ADDR") in settings.INTERNAL_IPS: + return True + + # Test: Docker + try: + # This is a hack for docker installations. It attempts to look + # up the IP address of the docker host. + # This is not guaranteed to work. + docker_ip = ( + # Convert the last segment of the IP address to be .1 + ".".join(socket.gethostbyname("host.docker.internal").rsplit(".")[:-1]) + + ".1" + ) + if request.META.get("REMOTE_ADDR") == docker_ip: + return True + except socket.gaierror: + # It's fine if the lookup errored since they may not be using docker + pass + + # No test passed + return False @lru_cache(maxsize=None) @@ -77,13 +100,7 @@ def __call__(self, request): response.headers[header] = value # Check for responses where the toolbar can't be inserted. - content_encoding = response.get("Content-Encoding", "") - content_type = response.get("Content-Type", "").split(";")[0] - if ( - getattr(response, "streaming", False) - or content_encoding != "" - or content_type not in _HTML_TYPES - ): + if not is_processable_html_response(response): return response # Insert the toolbar in the response. diff --git a/debug_toolbar/panels/__init__.py b/debug_toolbar/panels/__init__.py index 57f385a5e..a3869387f 100644 --- a/debug_toolbar/panels/__init__.py +++ b/debug_toolbar/panels/__init__.py @@ -1,4 +1,5 @@ from django.template.loader import render_to_string +from django.utils.functional import classproperty from debug_toolbar import settings as dt_settings from debug_toolbar.utils import get_name_from_obj @@ -12,15 +13,22 @@ class Panel: def __init__(self, toolbar, get_response): self.toolbar = toolbar self.get_response = get_response + self.from_store = False # Private panel properties - @property - def panel_id(self): - return self.__class__.__name__ + @classproperty + def panel_id(cls): + return cls.__name__ @property def enabled(self) -> bool: + if self.from_store: + # If the toolbar was loaded from the store the existence of + # recorded data indicates whether it was enabled or not. + # We can't use the remainder of the logic since we don't have + # a request to work off of. + return bool(self.get_stats()) # The user's cookies should override the default value cookie_value = self.toolbar.request.COOKIES.get("djdt" + self.panel_id) if cookie_value is not None: @@ -168,6 +176,9 @@ def record_stats(self, stats): Each call to ``record_stats`` updates the statistics dictionary. """ self.toolbar.stats.setdefault(self.panel_id, {}).update(stats) + self.toolbar.store.save_panel( + self.toolbar.request_id, self.panel_id, self.toolbar.stats[self.panel_id] + ) def get_stats(self): """ @@ -251,6 +262,15 @@ def generate_server_timing(self, request, response): Does not return a value. """ + def load_stats_from_store(self, data): + """ + Instantiate the panel from serialized data. + + Return the panel instance. + """ + self.toolbar.stats.setdefault(self.panel_id, {}).update(data) + self.from_store = True + @classmethod def run_checks(cls): """ diff --git a/debug_toolbar/panels/alerts.py b/debug_toolbar/panels/alerts.py new file mode 100644 index 000000000..530e384e6 --- /dev/null +++ b/debug_toolbar/panels/alerts.py @@ -0,0 +1,149 @@ +from html.parser import HTMLParser + +from django.utils.translation import gettext_lazy as _ + +from debug_toolbar.panels import Panel +from debug_toolbar.utils import is_processable_html_response + + +class FormParser(HTMLParser): + """ + HTML form parser, used to check for invalid configurations of forms that + take file inputs. + """ + + def __init__(self): + super().__init__() + self.in_form = False + self.current_form = {} + self.forms = [] + self.form_ids = [] + self.referenced_file_inputs = [] + + def handle_starttag(self, tag, attrs): + attrs = dict(attrs) + if tag == "form": + self.in_form = True + form_id = attrs.get("id") + if form_id: + self.form_ids.append(form_id) + self.current_form = { + "file_form": False, + "form_attrs": attrs, + "submit_element_attrs": [], + } + elif ( + self.in_form + and tag == "input" + and attrs.get("type") == "file" + and (not attrs.get("form") or attrs.get("form") == "") + ): + self.current_form["file_form"] = True + elif ( + self.in_form + and ( + (tag == "input" and attrs.get("type") in {"submit", "image"}) + or tag == "button" + ) + and (not attrs.get("form") or attrs.get("form") == "") + ): + self.current_form["submit_element_attrs"].append(attrs) + elif tag == "input" and attrs.get("form"): + self.referenced_file_inputs.append(attrs) + + def handle_endtag(self, tag): + if tag == "form" and self.in_form: + self.forms.append(self.current_form) + self.in_form = False + + +class AlertsPanel(Panel): + """ + A panel to alert users to issues. + """ + + messages = { + "form_id_missing_enctype": _( + 'Form with id "{form_id}" contains file input, but does not have the attribute enctype="multipart/form-data".' + ), + "form_missing_enctype": _( + 'Form contains file input, but does not have the attribute enctype="multipart/form-data".' + ), + "input_refs_form_missing_enctype": _( + 'Input element references form with id "{form_id}", but the form does not have the attribute enctype="multipart/form-data".' + ), + } + + title = _("Alerts") + + template = "debug_toolbar/panels/alerts.html" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.alerts = [] + + @property + def nav_subtitle(self): + if alerts := self.get_stats().get("alerts"): + alert_text = "alert" if len(alerts) == 1 else "alerts" + return f"{len(alerts)} {alert_text}" + else: + return "" + + def add_alert(self, alert): + self.alerts.append(alert) + + def check_invalid_file_form_configuration(self, html_content): + """ + Inspects HTML content for a form that includes a file input but does + not have the encoding type set to multipart/form-data, and warns the + user if so. + """ + parser = FormParser() + parser.feed(html_content) + + # Check for file inputs directly inside a form that do not reference + # any form through the form attribute + for form in parser.forms: + if ( + form["file_form"] + and form["form_attrs"].get("enctype") != "multipart/form-data" + and not any( + elem.get("formenctype") == "multipart/form-data" + for elem in form["submit_element_attrs"] + ) + ): + if form_id := form["form_attrs"].get("id"): + alert = self.messages["form_id_missing_enctype"].format( + form_id=form_id + ) + else: + alert = self.messages["form_missing_enctype"] + self.add_alert({"alert": alert}) + + # Check for file inputs that reference a form + form_attrs_by_id = { + form["form_attrs"].get("id"): form["form_attrs"] for form in parser.forms + } + + for attrs in parser.referenced_file_inputs: + form_id = attrs.get("form") + if form_id and attrs.get("type") == "file": + form_attrs = form_attrs_by_id.get(form_id) + if form_attrs and form_attrs.get("enctype") != "multipart/form-data": + alert = self.messages["input_refs_form_missing_enctype"].format( + form_id=form_id + ) + self.add_alert({"alert": alert}) + + return self.alerts + + def generate_stats(self, request, response): + if is_processable_html_response(response): + html_content = response.content.decode(response.charset) + self.check_invalid_file_form_configuration(html_content) + + # Further alert checks can go here + + # Write all alerts to record_stats + self.record_stats({"alerts": self.alerts}) diff --git a/debug_toolbar/panels/cache.py b/debug_toolbar/panels/cache.py index 4c7bf5af7..8235d37ff 100644 --- a/debug_toolbar/panels/cache.py +++ b/debug_toolbar/panels/cache.py @@ -169,16 +169,17 @@ def _record_call(self, cache, name, original_method, args, kwargs): @property def nav_subtitle(self): - cache_calls = len(self.calls) + stats = self.get_stats() + cache_calls = len(stats.get("calls")) return ngettext( "%(cache_calls)d call in %(time).2fms", "%(cache_calls)d calls in %(time).2fms", cache_calls, - ) % {"cache_calls": cache_calls, "time": self.total_time} + ) % {"cache_calls": cache_calls, "time": stats.get("total_time")} @property def title(self): - count = len(getattr(settings, "CACHES", ["default"])) + count = self.get_stats().get("total_caches") return ngettext( "Cache calls from %(count)d backend", "Cache calls from %(count)d backends", @@ -214,6 +215,7 @@ def generate_stats(self, request, response): "hits": self.hits, "misses": self.misses, "counts": self.counts, + "total_caches": len(getattr(settings, "CACHES", ["default"])), } ) diff --git a/debug_toolbar/panels/history/__init__.py b/debug_toolbar/panels/history/__init__.py index 52ceb7984..193ced242 100644 --- a/debug_toolbar/panels/history/__init__.py +++ b/debug_toolbar/panels/history/__init__.py @@ -1,3 +1,3 @@ from debug_toolbar.panels.history.panel import HistoryPanel -__all__ = ["HistoryPanel"] +__all__ = [HistoryPanel.panel_id] diff --git a/debug_toolbar/panels/history/forms.py b/debug_toolbar/panels/history/forms.py index 952b2409d..2aec18c34 100644 --- a/debug_toolbar/panels/history/forms.py +++ b/debug_toolbar/panels/history/forms.py @@ -5,8 +5,8 @@ class HistoryStoreForm(forms.Form): """ Validate params - store_id: The key for the store instance to be fetched. + request_id: The key for the store instance to be fetched. """ - store_id = forms.CharField(widget=forms.HiddenInput()) + request_id = forms.CharField(widget=forms.HiddenInput()) exclude_history = forms.BooleanField(widget=forms.HiddenInput(), required=False) diff --git a/debug_toolbar/panels/history/panel.py b/debug_toolbar/panels/history/panel.py index 508a60577..81dbc71d9 100644 --- a/debug_toolbar/panels/history/panel.py +++ b/debug_toolbar/panels/history/panel.py @@ -23,9 +23,9 @@ class HistoryPanel(Panel): def get_headers(self, request): headers = super().get_headers(request) observe_request = self.toolbar.get_observe_request() - store_id = self.toolbar.store_id - if store_id and observe_request(request): - headers["djdt-store-id"] = store_id + request_id = self.toolbar.request_id + if request_id and observe_request(request): + headers["djdt-request-id"] = request_id return headers @property @@ -86,23 +86,25 @@ def content(self): Fetch every store for the toolbar and include it in the template. """ - stores = {} - for id, toolbar in reversed(self.toolbar._store.items()): - stores[id] = { - "toolbar": toolbar, + toolbar_history = {} + for request_id in reversed(self.toolbar.store.request_ids()): + toolbar_history[request_id] = { + "history_stats": self.toolbar.store.panel( + request_id, HistoryPanel.panel_id + ), "form": HistoryStoreForm( - initial={"store_id": id, "exclude_history": True} + initial={"request_id": request_id, "exclude_history": True} ), } return render_to_string( self.template, { - "current_store_id": self.toolbar.store_id, - "stores": stores, + "current_request_id": self.toolbar.request_id, + "toolbar_history": toolbar_history, "refresh_form": HistoryStoreForm( initial={ - "store_id": self.toolbar.store_id, + "request_id": self.toolbar.request_id, "exclude_history": True, } ), diff --git a/debug_toolbar/panels/history/views.py b/debug_toolbar/panels/history/views.py index 3fcbd9b32..0cb6885f8 100644 --- a/debug_toolbar/panels/history/views.py +++ b/debug_toolbar/panels/history/views.py @@ -3,6 +3,7 @@ from debug_toolbar.decorators import render_with_toolbar_language, require_show_toolbar from debug_toolbar.panels.history.forms import HistoryStoreForm +from debug_toolbar.store import get_store from debug_toolbar.toolbar import DebugToolbar @@ -13,12 +14,12 @@ def history_sidebar(request): form = HistoryStoreForm(request.GET) if form.is_valid(): - store_id = form.cleaned_data["store_id"] - toolbar = DebugToolbar.fetch(store_id) + request_id = form.cleaned_data["request_id"] + toolbar = DebugToolbar.fetch(request_id) exclude_history = form.cleaned_data["exclude_history"] context = {} if toolbar is None: - # When the store_id has been popped already due to + # When the request_id has been popped already due to # RESULTS_CACHE_SIZE return JsonResponse(context) for panel in toolbar.panels: @@ -46,23 +47,26 @@ def history_refresh(request): if form.is_valid(): requests = [] # Convert to list to handle mutations happening in parallel - for id, toolbar in list(DebugToolbar._store.items()): + for request_id in get_store().request_ids(): + toolbar = DebugToolbar.fetch(request_id) requests.append( { - "id": id, + "id": request_id, "content": render_to_string( "debug_toolbar/panels/history_tr.html", { - "id": id, - "store_context": { - "toolbar": toolbar, - "form": HistoryStoreForm( - initial={ - "store_id": id, - "exclude_history": True, - } - ), + "request_id": request_id, + "history_context": { + "history_stats": toolbar.store.panel( + request_id, "HistoryPanel" + ) }, + "form": HistoryStoreForm( + initial={ + "request_id": request_id, + "exclude_history": True, + } + ), }, ), } diff --git a/debug_toolbar/panels/profiling.py b/debug_toolbar/panels/profiling.py index 9d10229ad..6a50f6326 100644 --- a/debug_toolbar/panels/profiling.py +++ b/debug_toolbar/panels/profiling.py @@ -25,6 +25,7 @@ def __init__( self.id = id self.parent_ids = parent_ids or [] self.hsv = hsv + self.has_subfuncs = False def parent_classes(self): return self.parent_classes @@ -42,10 +43,14 @@ def is_project_func(self): """ if hasattr(settings, "BASE_DIR"): file_name, _, _ = self.func - return ( - str(settings.BASE_DIR) in file_name - and "/site-packages/" not in file_name - and "/dist-packages/" not in file_name + base_dir = str(settings.BASE_DIR) + + file_name = os.path.normpath(file_name) + base_dir = os.path.normpath(base_dir) + + return file_name.startswith(base_dir) and not any( + directory in file_name.split(os.path.sep) + for directory in ["site-packages", "dist-packages"] ) return None @@ -82,12 +87,10 @@ def func_std_string(self): # match what old profile produced ) def subfuncs(self): - i = 0 h, s, v = self.hsv count = len(self.statobj.all_callees[self.func]) - for func, stats in self.statobj.all_callees[self.func].items(): - i += 1 - h1 = h + (i / count) / (self.depth + 1) + for i, (func, stats) in enumerate(self.statobj.all_callees[self.func].items()): + h1 = h + ((i + 1) / count) / (self.depth + 1) s1 = 0 if stats[3] == 0 else s * (stats[3] / self.stats[3]) yield FunctionCall( self.statobj, @@ -128,6 +131,21 @@ def cumtime_per_call(self): def indent(self): return 16 * self.depth + def serialize(self): + return { + "has_subfuncs": self.has_subfuncs, + "id": self.id, + "parent_ids": self.parent_ids, + "is_project_func": self.is_project_func(), + "indent": self.indent(), + "func_std_string": self.func_std_string(), + "cumtime": self.cumtime(), + "cumtime_per_call": self.cumtime_per_call(), + "tottime": self.tottime(), + "tottime_per_call": self.tottime_per_call(), + "count": self.count(), + } + class ProfilingPanel(Panel): """ @@ -145,7 +163,6 @@ def process_request(self, request): def add_node(self, func_list, func, max_depth, cum_time): func_list.append(func) - func.has_subfuncs = False if func.depth < max_depth: for subfunc in func.subfuncs(): # Always include the user's code @@ -179,4 +196,4 @@ def generate_stats(self, request, response): dt_settings.get_config()["PROFILER_MAX_DEPTH"], cum_time_threshold, ) - self.record_stats({"func_list": func_list}) + self.record_stats({"func_list": [func.serialize() for func in func_list]}) diff --git a/debug_toolbar/panels/settings.py b/debug_toolbar/panels/settings.py index 7b27c6243..c14c1f28b 100644 --- a/debug_toolbar/panels/settings.py +++ b/debug_toolbar/panels/settings.py @@ -1,4 +1,4 @@ -from django.conf import settings +from django.utils.encoding import force_str from django.utils.translation import gettext_lazy as _ from django.views.debug import get_default_exception_reporter_filter @@ -17,7 +17,16 @@ class SettingsPanel(Panel): nav_title = _("Settings") def title(self): - return _("Settings from %s") % settings.SETTINGS_MODULE + return _("Settings from %s") % self.get_stats()["settings"].get( + "SETTINGS_MODULE" + ) def generate_stats(self, request, response): - self.record_stats({"settings": dict(sorted(get_safe_settings().items()))}) + self.record_stats( + { + "settings": { + key: force_str(value) + for key, value in sorted(get_safe_settings().items()) + } + } + ) diff --git a/debug_toolbar/panels/sql/__init__.py b/debug_toolbar/panels/sql/__init__.py index 46c68a3c6..9da548f7f 100644 --- a/debug_toolbar/panels/sql/__init__.py +++ b/debug_toolbar/panels/sql/__init__.py @@ -1,3 +1,3 @@ from debug_toolbar.panels.sql.panel import SQLPanel -__all__ = ["SQLPanel"] +__all__ = [SQLPanel.panel_id] diff --git a/debug_toolbar/panels/sql/forms.py b/debug_toolbar/panels/sql/forms.py index 0515c5c8e..4caa29836 100644 --- a/debug_toolbar/panels/sql/forms.py +++ b/debug_toolbar/panels/sql/forms.py @@ -4,25 +4,22 @@ from django.core.exceptions import ValidationError from django.db import connections from django.utils.functional import cached_property +from django.utils.translation import gettext_lazy as _ from debug_toolbar.panels.sql.utils import reformat_sql +from debug_toolbar.toolbar import DebugToolbar class SQLSelectForm(forms.Form): """ Validate params - sql: The sql statement with interpolated params - raw_sql: The sql statement with placeholders - params: JSON encoded parameter values - duration: time for SQL to execute passed in from toolbar just for redisplay + request_id: The identifier for the request + query_id: The identifier for the query """ - sql = forms.CharField() - raw_sql = forms.CharField() - params = forms.CharField() - alias = forms.CharField(required=False, initial="default") - duration = forms.FloatField() + request_id = forms.CharField() + djdt_query_id = forms.CharField() def clean_raw_sql(self): value = self.cleaned_data["raw_sql"] @@ -48,12 +45,91 @@ def clean_alias(self): return value + def clean(self): + from debug_toolbar.panels.sql import SQLPanel + + cleaned_data = super().clean() + toolbar = DebugToolbar.fetch( + self.cleaned_data["request_id"], panel_id=SQLPanel.panel_id + ) + if toolbar is None: + raise ValidationError(_("Data for this panel isn't available anymore.")) + + panel = toolbar.get_panel_by_id(SQLPanel.panel_id) + # Find the query for this form submission + query = None + for q in panel.get_stats()["queries"]: + if q["djdt_query_id"] != self.cleaned_data["djdt_query_id"]: + continue + else: + query = q + break + if not query: + raise ValidationError(_("Invalid query id.")) + cleaned_data["query"] = query + return cleaned_data + + def select(self): + query = self.cleaned_data["query"] + sql = query["raw_sql"] + params = json.loads(query["params"]) + with self.cursor as cursor: + cursor.execute(sql, params) + headers = [d[0] for d in cursor.description] + result = cursor.fetchall() + return result, headers + + def explain(self): + query = self.cleaned_data["query"] + sql = query["raw_sql"] + params = json.loads(query["params"]) + vendor = query["vendor"] + with self.cursor as cursor: + if vendor == "sqlite": + # SQLite's EXPLAIN dumps the low-level opcodes generated for a query; + # EXPLAIN QUERY PLAN dumps a more human-readable summary + # See https://www.sqlite.org/lang_explain.html for details + cursor.execute(f"EXPLAIN QUERY PLAN {sql}", params) + elif vendor == "postgresql": + cursor.execute(f"EXPLAIN ANALYZE {sql}", params) + else: + cursor.execute(f"EXPLAIN {sql}", params) + headers = [d[0] for d in cursor.description] + result = cursor.fetchall() + return result, headers + + def profile(self): + query = self.cleaned_data["query"] + sql = query["raw_sql"] + params = json.loads(query["params"]) + with self.cursor as cursor: + cursor.execute("SET PROFILING=1") # Enable profiling + cursor.execute(sql, params) # Execute SELECT + cursor.execute("SET PROFILING=0") # Disable profiling + # The Query ID should always be 1 here but I'll subselect to get + # the last one just in case... + cursor.execute( + """ + SELECT * + FROM information_schema.profiling + WHERE query_id = ( + SELECT query_id + FROM information_schema.profiling + ORDER BY query_id DESC + LIMIT 1 + ) + """ + ) + headers = [d[0] for d in cursor.description] + result = cursor.fetchall() + return result, headers + def reformat_sql(self): - return reformat_sql(self.cleaned_data["sql"], with_toggle=False) + return reformat_sql(self.cleaned_data["query"]["sql"], with_toggle=False) @property def connection(self): - return connections[self.cleaned_data["alias"]] + return connections[self.cleaned_data["query"]["alias"]] @cached_property def cursor(self): diff --git a/debug_toolbar/panels/sql/panel.py b/debug_toolbar/panels/sql/panel.py index 58c1c2738..698430325 100644 --- a/debug_toolbar/panels/sql/panel.py +++ b/debug_toolbar/panels/sql/panel.py @@ -1,9 +1,10 @@ import uuid from collections import defaultdict -from copy import copy from django.db import connections +from django.template.loader import render_to_string from django.urls import path +from django.utils.functional import cached_property from django.utils.translation import gettext_lazy as _, ngettext from debug_toolbar import settings as dt_settings @@ -81,7 +82,7 @@ def _similar_query_key(query): def _duplicate_query_key(query): - raw_params = () if query["raw_params"] is None else tuple(query["raw_params"]) + raw_params = () if query["params"] is None else tuple(query["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 @@ -139,6 +140,7 @@ def current_transaction_id(self, alias): return trans_id def record(self, **kwargs): + kwargs["djdt_query_id"] = uuid.uuid4().hex self._queries.append(kwargs) alias = kwargs["alias"] if alias not in self._databases: @@ -157,19 +159,20 @@ def record(self, **kwargs): @property def nav_subtitle(self): - query_count = len(self._queries) + stats = self.get_stats() + query_count = len(stats.get("queries", [])) return ngettext( "%(query_count)d query in %(sql_time).2fms", "%(query_count)d queries in %(sql_time).2fms", query_count, ) % { "query_count": query_count, - "sql_time": self._sql_time, + "sql_time": stats.get("sql_time"), } @property def title(self): - count = len(self._databases) + count = len(self.get_stats().get("databases")) return ngettext( "SQL queries from %(count)d connection", "SQL queries from %(count)d connections", @@ -197,8 +200,6 @@ def disable_instrumentation(self): connection._djdt_logger = None def generate_stats(self, request, response): - colors = contrasting_color_generator() - trace_colors = defaultdict(lambda: next(colors)) similar_query_groups = defaultdict(list) duplicate_query_groups = defaultdict(list) @@ -255,14 +256,6 @@ def generate_stats(self, request, response): query["trans_status"] = get_transaction_status_display( query["vendor"], query["trans_status"] ) - - query["form"] = SignedDataForm( - auto_id=None, initial=SQLSelectForm(initial=copy(query)).initial - ) - - if query["sql"]: - query["sql"] = reformat_sql(query["sql"], with_toggle=True) - query["is_slow"] = query["duration"] > sql_warning_threshold query["is_select"] = ( query["raw_sql"].lower().lstrip().startswith("select") @@ -276,9 +269,6 @@ def generate_stats(self, request, response): query["start_offset"] = width_ratio_tally query["end_offset"] = query["width_ratio"] + query["start_offset"] width_ratio_tally += query["width_ratio"] - query["stacktrace"] = render_stacktrace(query["stacktrace"]) - - query["trace_color"] = trace_colors[query["stacktrace"]] last_by_alias[alias] = query @@ -311,3 +301,38 @@ def generate_server_timing(self, request, response): title = "SQL {} queries".format(len(stats.get("queries", []))) value = stats.get("sql_time", 0) self.record_server_timing("sql_time", title, value) + + def record_stats(self, stats): + """ + Store data gathered by the panel. ``stats`` is a :class:`dict`. + + Each call to ``record_stats`` updates the statistics dictionary. + """ + for query in stats.get("queries", []): + query["params"] + return super().record_stats(stats) + + # Cache the content property since it manipulates the queries in the stats + # This allows the caller to treat content as idempotent + @cached_property + def content(self): + if self.has_content: + stats = self.get_stats() + colors = contrasting_color_generator() + trace_colors = defaultdict(lambda: next(colors)) + + for query in stats.get("queries", []): + query["sql"] = reformat_sql(query["sql"], with_toggle=True) + query["form"] = SignedDataForm( + auto_id=None, + initial=SQLSelectForm( + initial={ + "djdt_query_id": query["djdt_query_id"], + "request_id": self.toolbar.request_id, + } + ).initial, + ) + query["stacktrace"] = render_stacktrace(query["stacktrace"]) + query["trace_color"] = trace_colors[query["stacktrace"]] + + return render_to_string(self.template, stats) diff --git a/debug_toolbar/panels/sql/tracking.py b/debug_toolbar/panels/sql/tracking.py index 0c53dc2c5..97cecd505 100644 --- a/debug_toolbar/panels/sql/tracking.py +++ b/debug_toolbar/panels/sql/tracking.py @@ -109,21 +109,6 @@ class NormalCursorMixin(DjDTCursorWrapperMixin): Wraps a cursor and logs queries. """ - def _quote_expr(self, element): - if isinstance(element, str): - return "'%s'" % element.replace("'", "''") - else: - return repr(element) - - def _quote_params(self, params): - if not params: - return params - if isinstance(params, dict): - return {key: self._quote_expr(value) for key, value in params.items()} - if isinstance(params, tuple): - return tuple(self._quote_expr(p) for p in params) - return [self._quote_expr(p) for p in params] - def _decode(self, param): if PostgresJson and isinstance(param, PostgresJson): # psycopg3 @@ -157,9 +142,7 @@ def _last_executed_query(self, sql, params): # 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) - ) + return self.db.ops.last_executed_query(self.cursor, sql, params) finally: self.db._djdt_logger = self.logger @@ -201,7 +184,6 @@ def _record(self, method, sql, params): "duration": duration, "raw_sql": sql, "params": _params, - "raw_params": params, "stacktrace": get_stack_trace(skip=2), "template_info": template_info, } diff --git a/debug_toolbar/panels/sql/views.py b/debug_toolbar/panels/sql/views.py index 4b6ced9da..b498c140a 100644 --- a/debug_toolbar/panels/sql/views.py +++ b/debug_toolbar/panels/sql/views.py @@ -5,6 +5,7 @@ from debug_toolbar.decorators import render_with_toolbar_language, require_show_toolbar from debug_toolbar.forms import SignedDataForm from debug_toolbar.panels.sql.forms import SQLSelectForm +from debug_toolbar.panels.sql.utils import reformat_sql def get_signed_data(request): @@ -27,19 +28,14 @@ def sql_select(request): form = SQLSelectForm(verified_data) if form.is_valid(): - sql = form.cleaned_data["raw_sql"] - params = form.cleaned_data["params"] - with form.cursor as cursor: - cursor.execute(sql, params) - headers = [d[0] for d in cursor.description] - result = cursor.fetchall() - + query = form.cleaned_data["query"] + result, headers = form.select() context = { "result": result, - "sql": form.reformat_sql(), - "duration": form.cleaned_data["duration"], + "sql": reformat_sql(query["sql"], with_toggle=False), + "duration": query["duration"], "headers": headers, - "alias": form.cleaned_data["alias"], + "alias": query["alias"], } content = render_to_string("debug_toolbar/panels/sql_select.html", context) return JsonResponse({"content": content}) @@ -57,28 +53,14 @@ def sql_explain(request): form = SQLSelectForm(verified_data) if form.is_valid(): - sql = form.cleaned_data["raw_sql"] - params = form.cleaned_data["params"] - vendor = form.connection.vendor - with form.cursor as cursor: - if vendor == "sqlite": - # SQLite's EXPLAIN dumps the low-level opcodes generated for a query; - # EXPLAIN QUERY PLAN dumps a more human-readable summary - # See https://www.sqlite.org/lang_explain.html for details - cursor.execute(f"EXPLAIN QUERY PLAN {sql}", params) - elif vendor == "postgresql": - cursor.execute(f"EXPLAIN ANALYZE {sql}", params) - else: - cursor.execute(f"EXPLAIN {sql}", params) - headers = [d[0] for d in cursor.description] - result = cursor.fetchall() - + query = form.cleaned_data["query"] + result, headers = form.explain() context = { "result": result, - "sql": form.reformat_sql(), - "duration": form.cleaned_data["duration"], + "sql": reformat_sql(query["sql"], with_toggle=False), + "duration": query["duration"], "headers": headers, - "alias": form.cleaned_data["alias"], + "alias": query["alias"], } content = render_to_string("debug_toolbar/panels/sql_explain.html", context) return JsonResponse({"content": content}) @@ -96,45 +78,25 @@ def sql_profile(request): form = SQLSelectForm(verified_data) if form.is_valid(): - sql = form.cleaned_data["raw_sql"] - params = form.cleaned_data["params"] + query = form.cleaned_data["query"] result = None headers = None result_error = None - with form.cursor as cursor: - try: - cursor.execute("SET PROFILING=1") # Enable profiling - cursor.execute(sql, params) # Execute SELECT - cursor.execute("SET PROFILING=0") # Disable profiling - # The Query ID should always be 1 here but I'll subselect to get - # the last one just in case... - cursor.execute( - """ - SELECT * - FROM information_schema.profiling - WHERE query_id = ( - SELECT query_id - FROM information_schema.profiling - ORDER BY query_id DESC - LIMIT 1 - ) - """ - ) - headers = [d[0] for d in cursor.description] - result = cursor.fetchall() - except Exception: - result_error = ( - "Profiling is either not available or not supported by your " - "database." - ) + try: + result, headers = form.profile() + except Exception: + result_error = ( + "Profiling is either not available or not supported by your " + "database." + ) context = { "result": result, "result_error": result_error, "sql": form.reformat_sql(), - "duration": form.cleaned_data["duration"], + "duration": query["duration"], "headers": headers, - "alias": form.cleaned_data["alias"], + "alias": query["alias"], } content = render_to_string("debug_toolbar/panels/sql_profile.html", context) return JsonResponse({"content": content}) diff --git a/debug_toolbar/panels/staticfiles.py b/debug_toolbar/panels/staticfiles.py index 5f9efb5c3..1f0718021 100644 --- a/debug_toolbar/panels/staticfiles.py +++ b/debug_toolbar/panels/staticfiles.py @@ -4,7 +4,6 @@ from django.conf import settings from django.contrib.staticfiles import finders, storage -from django.core.checks import Warning from django.utils.functional import LazyObject from django.utils.translation import gettext_lazy as _, ngettext @@ -79,9 +78,10 @@ class StaticFilesPanel(panels.Panel): @property def title(self): + stats = self.get_stats() return _("Static files (%(num_found)s found, %(num_used)s used)") % { - "num_found": self.num_found, - "num_used": self.num_used, + "num_found": stats.get("num_found"), + "num_used": stats.get("num_used"), } def __init__(self, *args, **kwargs): @@ -95,16 +95,11 @@ def enable_instrumentation(self): def disable_instrumentation(self): storage.staticfiles_storage = _original_storage - @property - def num_used(self): - stats = self.get_stats() - return stats and stats["num_used"] - nav_title = _("Static files") @property def nav_subtitle(self): - num_used = self.num_used + num_used = self.get_stats().get("num_used") return ngettext( "%(num_used)s file used", "%(num_used)s files used", num_used ) % {"num_used": num_used} @@ -178,27 +173,3 @@ def get_staticfiles_apps(self): if app not in apps: apps.append(app) return apps - - @classmethod - def run_checks(cls): - """ - Check that the integration is configured correctly for the panel. - - Specifically look for static files that haven't been collected yet. - - Return a list of :class: `django.core.checks.CheckMessage` instances. - """ - errors = [] - for finder in finders.get_finders(): - try: - for path, finder_storage in finder.list([]): - finder_storage.path(path) - except OSError: - errors.append( - Warning( - "debug_toolbar requires the STATICFILES_DIRS directories to exist.", - hint="Running manage.py collectstatic may help uncover the issue.", - id="debug_toolbar.staticfiles.W001", - ) - ) - return errors diff --git a/debug_toolbar/panels/templates/__init__.py b/debug_toolbar/panels/templates/__init__.py index a1d509b9e..5cd78bbb3 100644 --- a/debug_toolbar/panels/templates/__init__.py +++ b/debug_toolbar/panels/templates/__init__.py @@ -1,3 +1,3 @@ from debug_toolbar.panels.templates.panel import TemplatesPanel -__all__ = ["TemplatesPanel"] +__all__ = [TemplatesPanel.panel_id] diff --git a/debug_toolbar/panels/templates/jinja2.py b/debug_toolbar/panels/templates/jinja2.py new file mode 100644 index 000000000..d343cb140 --- /dev/null +++ b/debug_toolbar/panels/templates/jinja2.py @@ -0,0 +1,23 @@ +import functools + +from django.template.backends.jinja2 import Template as JinjaTemplate +from django.template.context import make_context +from django.test.signals import template_rendered + + +def patch_jinja_render(): + orig_render = JinjaTemplate.render + + @functools.wraps(orig_render) + def wrapped_render(self, context=None, request=None): + # This patching of render only instruments the rendering + # of the immediate template. It won't include the parent template(s). + self.name = self.template.name + template_rendered.send( + sender=self, template=self, context=make_context(context, request) + ) + return orig_render(self, context, request) + + if JinjaTemplate.render != wrapped_render: + JinjaTemplate.original_render = JinjaTemplate.render + JinjaTemplate.render = wrapped_render diff --git a/debug_toolbar/panels/templates/panel.py b/debug_toolbar/panels/templates/panel.py index 72565f016..f35e6aa7b 100644 --- a/debug_toolbar/panels/templates/panel.py +++ b/debug_toolbar/panels/templates/panel.py @@ -1,4 +1,5 @@ from contextlib import contextmanager +from importlib.util import find_spec from os.path import normpath from pprint import pformat, saferepr @@ -9,12 +10,18 @@ from django.test.signals import template_rendered from django.test.utils import instrumented_test_render from django.urls import path +from django.utils.encoding import force_str from django.utils.translation import gettext_lazy as _ from debug_toolbar.panels import Panel from debug_toolbar.panels.sql.tracking import SQLQueryTriggered, allow_sql from debug_toolbar.panels.templates import views +if find_spec("jinja2"): + from debug_toolbar.panels.templates.jinja2 import patch_jinja_render + + patch_jinja_render() + # Monkey-patch to enable the template_rendered signal. The receiver returns # immediately when the panel is disabled to keep the overhead small. @@ -25,7 +32,6 @@ Template.original_render = Template._render Template._render = instrumented_test_render - # Monkey-patch to store items added by template context processors. The # overhead is sufficiently small to justify enabling it unconditionally. @@ -83,58 +89,11 @@ def _store_template_info(self, sender, **kwargs): if is_debug_toolbar_template: return - context_list = [] - for context_layer in context.dicts: - if hasattr(context_layer, "items") and context_layer: - # Check if the layer is in the cache. - pformatted = None - for key_values, _pformatted in self.pformat_layers: - if key_values == context_layer: - pformatted = _pformatted - break - - if pformatted is None: - temp_layer = {} - for key, value in context_layer.items(): - # Replace any request elements - they have a large - # Unicode representation and the request data is - # already made available from the Request panel. - if isinstance(value, http.HttpRequest): - temp_layer[key] = "<>" - # Replace the debugging sql_queries element. The SQL - # data is already made available from the SQL panel. - elif key == "sql_queries" and isinstance(value, list): - temp_layer[key] = "<>" - # Replace LANGUAGES, which is available in i18n context - # processor - elif key == "LANGUAGES" and isinstance(value, tuple): - temp_layer[key] = "<>" - # QuerySet would trigger the database: user can run the - # query from SQL Panel - elif isinstance(value, (QuerySet, RawQuerySet)): - temp_layer[key] = "<<{} of {}>>".format( - value.__class__.__name__.lower(), - value.model._meta.label, - ) - else: - token = allow_sql.set(False) # noqa: FBT003 - try: - saferepr(value) # this MAY trigger a db query - except SQLQueryTriggered: - temp_layer[key] = "<>" - except UnicodeEncodeError: - temp_layer[key] = "<>" - except Exception: - temp_layer[key] = "<>" - else: - temp_layer[key] = value - finally: - allow_sql.reset(token) - pformatted = pformat(temp_layer) - self.pformat_layers.append((context_layer, pformatted)) - context_list.append(pformatted) - - kwargs["context"] = context_list + kwargs["context"] = [ + context_layer + for context_layer in context.dicts + if hasattr(context_layer, "items") and context_layer + ] kwargs["context_processors"] = getattr(context, "context_processors", None) self.templates.append(kwargs) @@ -144,15 +103,16 @@ def _store_template_info(self, sender, **kwargs): @property def title(self): - num_templates = len(self.templates) + num_templates = len(self.get_stats()["templates"]) return _("Templates (%(num_templates)s rendered)") % { "num_templates": num_templates } @property def nav_subtitle(self): - if self.templates: - return self.templates[0]["template"].name + templates = self.get_stats()["templates"] + if templates: + return templates[0]["template"]["name"] return "" template = "debug_toolbar/panels/templates.html" @@ -167,6 +127,63 @@ def enable_instrumentation(self): def disable_instrumentation(self): template_rendered.disconnect(self._store_template_info) + def process_context_list(self, context_layers): + context_list = [] + for context_layer in context_layers: + # Check if the layer is in the cache. + pformatted = None + for key_values, _pformatted in self.pformat_layers: + if key_values == context_layer: + pformatted = _pformatted + break + + if pformatted is None: + temp_layer = {} + for key, value in context_layer.items(): + # Do not force evaluating LazyObject + if hasattr(value, "_wrapped"): + # SimpleLazyObject has __repr__ which includes actual value + # if it has been already evaluated + temp_layer[key] = repr(value) + # Replace any request elements - they have a large + # Unicode representation and the request data is + # already made available from the Request panel. + elif isinstance(value, http.HttpRequest): + temp_layer[key] = "<>" + # Replace the debugging sql_queries element. The SQL + # data is already made available from the SQL panel. + elif key == "sql_queries" and isinstance(value, list): + temp_layer[key] = "<>" + # Replace LANGUAGES, which is available in i18n context + # processor + elif key == "LANGUAGES" and isinstance(value, tuple): + temp_layer[key] = "<>" + # QuerySet would trigger the database: user can run the + # query from SQL Panel + elif isinstance(value, (QuerySet, RawQuerySet)): + temp_layer[key] = ( + f"<<{value.__class__.__name__.lower()} of {value.model._meta.label}>>" + ) + else: + token = allow_sql.set(False) + try: + saferepr(value) # this MAY trigger a db query + except SQLQueryTriggered: + temp_layer[key] = "<>" + except UnicodeEncodeError: + temp_layer[key] = "<>" + except Exception: + temp_layer[key] = "<>" + else: + temp_layer[key] = value + finally: + allow_sql.reset(token) + pformatted = pformat(temp_layer) + self.pformat_layers.append((context_layer, pformatted)) + context_list.append(pformatted) + + return context_list + def generate_stats(self, request, response): template_context = [] for template_data in self.templates: @@ -179,16 +196,30 @@ def generate_stats(self, request, response): else: template.origin_name = _("No origin") template.origin_hash = "" - info["template"] = template + info["template"] = { + "name": template.name, + "origin_name": template.origin_name, + "origin_hash": template.origin_hash, + } # Clean up context for better readability if self.toolbar.config["SHOW_TEMPLATE_CONTEXT"]: - context_list = template_data.get("context", []) - info["context"] = "\n".join(context_list) + if "context_list" not in template_data: + template_data["context_list"] = self.process_context_list( + template_data.get("context", []) + ) + info["context"] = "\n".join(template_data["context_list"]) template_context.append(info) # Fetch context_processors/template_dirs from any template if self.templates: - context_processors = self.templates[0]["context_processors"] + context_processors = ( + { + key: force_str(value) + for key, value in self.templates[0]["context_processors"].items() + } + if self.templates[0]["context_processors"] + else None + ) template = self.templates[0]["template"] # django templates have the 'engine' attribute, while jinja # templates use 'backend' diff --git a/debug_toolbar/panels/timer.py b/debug_toolbar/panels/timer.py index 554798e7d..6ef9f0d7c 100644 --- a/debug_toolbar/panels/timer.py +++ b/debug_toolbar/panels/timer.py @@ -19,11 +19,11 @@ class TimerPanel(Panel): def nav_subtitle(self): stats = self.get_stats() - if hasattr(self, "_start_rusage"): - utime = self._end_rusage.ru_utime - self._start_rusage.ru_utime - stime = self._end_rusage.ru_stime - self._start_rusage.ru_stime + if stats.get("utime"): + utime = stats.get("utime") + stime = stats.get("stime") return _("CPU: %(cum)0.2fms (%(total)0.2fms)") % { - "cum": (utime + stime) * 1000.0, + "cum": (utime + stime), "total": stats["total_time"], } elif "total_time" in stats: @@ -64,27 +64,44 @@ def process_request(self, request): self._start_rusage = resource.getrusage(resource.RUSAGE_SELF) return super().process_request(request) + def serialize_rusage(self, data): + fields_to_serialize = [ + "ru_utime", + "ru_stime", + "ru_nvcsw", + "ru_nivcsw", + "ru_minflt", + "ru_majflt", + ] + return {field: getattr(data, field) for field in fields_to_serialize} + def generate_stats(self, request, response): stats = {} if hasattr(self, "_start_time"): stats["total_time"] = (perf_counter() - self._start_time) * 1000 - if hasattr(self, "_start_rusage"): + if self.has_content: self._end_rusage = resource.getrusage(resource.RUSAGE_SELF) - stats["utime"] = 1000 * self._elapsed_ru("ru_utime") - stats["stime"] = 1000 * self._elapsed_ru("ru_stime") + start = self.serialize_rusage(self._start_rusage) + end = self.serialize_rusage(self._end_rusage) + stats.update( + { + "utime": 1000 * self._elapsed_ru(start, end, "ru_utime"), + "stime": 1000 * self._elapsed_ru(start, end, "ru_stime"), + "vcsw": self._elapsed_ru(start, end, "ru_nvcsw"), + "ivcsw": self._elapsed_ru(start, end, "ru_nivcsw"), + "minflt": self._elapsed_ru(start, end, "ru_minflt"), + "majflt": self._elapsed_ru(start, end, "ru_majflt"), + } + ) stats["total"] = stats["utime"] + stats["stime"] - stats["vcsw"] = self._elapsed_ru("ru_nvcsw") - stats["ivcsw"] = self._elapsed_ru("ru_nivcsw") - stats["minflt"] = self._elapsed_ru("ru_minflt") - stats["majflt"] = self._elapsed_ru("ru_majflt") # these are documented as not meaningful under Linux. If you're # running BSD feel free to enable them, and add any others that I # hadn't gotten to before I noticed that I was getting nothing but # zeroes and that the docs agreed. :-( # - # stats['blkin'] = self._elapsed_ru('ru_inblock') - # stats['blkout'] = self._elapsed_ru('ru_oublock') - # stats['swap'] = self._elapsed_ru('ru_nswap') + # stats['blkin'] = self._elapsed_ru(start, end, 'ru_inblock') + # stats['blkout'] = self._elapsed_ru(start, end, 'ru_oublock') + # stats['swap'] = self._elapsed_ru(start, end, 'ru_nswap') # stats['rss'] = self._end_rusage.ru_maxrss # stats['srss'] = self._end_rusage.ru_ixrss # stats['urss'] = self._end_rusage.ru_idrss @@ -102,5 +119,6 @@ def generate_server_timing(self, request, response): "total_time", "Elapsed time", stats.get("total_time", 0) ) - def _elapsed_ru(self, name): - return getattr(self._end_rusage, name) - getattr(self._start_rusage, name) + @staticmethod + def _elapsed_ru(start, end, name): + return end.get(name) - start.get(name) diff --git a/debug_toolbar/panels/versions.py b/debug_toolbar/panels/versions.py index d517ecfb3..a86dce94e 100644 --- a/debug_toolbar/panels/versions.py +++ b/debug_toolbar/panels/versions.py @@ -14,7 +14,7 @@ class VersionsPanel(Panel): @property def nav_subtitle(self): - return "Django %s" % django.get_version() + return "Django %s" % self.get_stats()["django_version"] title = _("Versions") @@ -27,7 +27,11 @@ def generate_stats(self, request, response): ] versions += list(self.gen_app_versions()) self.record_stats( - {"versions": sorted(versions, key=lambda v: v[0]), "paths": sys.path} + { + "django_version": django.get_version(), + "versions": sorted(versions, key=lambda v: v[0]), + "paths": sys.path, + } ) def gen_app_versions(self): diff --git a/debug_toolbar/settings.py b/debug_toolbar/settings.py index b2a07dcd9..3e75a6d01 100644 --- a/debug_toolbar/settings.py +++ b/debug_toolbar/settings.py @@ -1,3 +1,4 @@ +import sys import warnings from functools import lru_cache @@ -37,13 +38,15 @@ "PROFILER_CAPTURE_PROJECT_CODE": True, "PROFILER_MAX_DEPTH": 10, "PROFILER_THRESHOLD_RATIO": 8, - "SUPPRESS_SERIALIZATION_ERRORS": True, "SHOW_TEMPLATE_CONTEXT": True, "SKIP_TEMPLATE_PREFIXES": ("django/forms/widgets/", "admin/widgets/"), "SQL_WARNING_THRESHOLD": 500, # milliseconds "OBSERVE_REQUEST_CALLBACK": "debug_toolbar.toolbar.observe_request", "TOOLBAR_LANGUAGE": None, "TOOLBAR_STORE_CLASS": "debug_toolbar.store.MemoryStore", + "IS_RUNNING_TESTS": "test" in sys.argv, + "UPDATE_ON_FETCH": False, + "DEFAULT_THEME": "auto", } @@ -65,6 +68,7 @@ def get_config(): "debug_toolbar.panels.sql.SQLPanel", "debug_toolbar.panels.staticfiles.StaticFilesPanel", "debug_toolbar.panels.templates.TemplatesPanel", + "debug_toolbar.panels.alerts.AlertsPanel", "debug_toolbar.panels.cache.CachePanel", "debug_toolbar.panels.signals.SignalsPanel", "debug_toolbar.panels.redirects.RedirectsPanel", diff --git a/debug_toolbar/static/debug_toolbar/css/toolbar.css b/debug_toolbar/static/debug_toolbar/css/toolbar.css index a35286a1f..e495eeb0c 100644 --- a/debug_toolbar/static/debug_toolbar/css/toolbar.css +++ b/debug_toolbar/static/debug_toolbar/css/toolbar.css @@ -11,10 +11,77 @@ "Noto Color Emoji"; } +:root, +#djDebug[data-theme="light"] { + --djdt-font-color: black; + --djdt-background-color: white; + --djdt-panel-content-background-color: #eee; + --djdt-panel-content-table-background-color: var(--djdt-background-color); + --djdt-panel-title-background-color: #ffc; + --djdt-djdt-panel-content-table-strip-background-color: #f5f5f5; + --djdt--highlighted-background-color: lightgrey; + --djdt-toggle-template-background-color: #bbb; + + --djdt-sql-font-color: #333; + --djdt-pre-text-color: #555; + --djdt-path-and-locals: #777; + --djdt-stack-span-color: black; + --djdt-template-highlight-color: #333; + + --djdt-table-border-color: #ccc; + --djdt-button-border-color: var(--djdt-table-border-color); + --djdt-pre-border-color: var(--djdt-table-border-color); + --djdt-raw-border-color: var(--djdt-table-border-color); +} + +@media (prefers-color-scheme: dark) { + :root { + --djdt-font-color: #8393a7; + --djdt-background-color: #1e293bff; + --djdt-panel-content-background-color: #0f1729ff; + --djdt-panel-title-background-color: #242432; + --djdt-djdt-panel-content-table-strip-background-color: #324154ff; + --djdt--highlighted-background-color: #2c2a7dff; + --djdt-toggle-template-background-color: #282755; + + --djdt-sql-font-color: var(--djdt-font-color); + --djdt-pre-text-color: var(--djdt-font-color); + --djdt-path-and-locals: #65758cff; + --djdt-stack-span-color: #7c8fa4; + --djdt-template-highlight-color: var(--djdt-stack-span-color); + + --djdt-table-border-color: #324154ff; + --djdt-button-border-color: var(--djdt-table-border-color); + --djdt-pre-border-color: var(--djdt-table-border-color); + --djdt-raw-border-color: var(--djdt-table-border-color); + } +} + +#djDebug[data-theme="dark"] { + --djdt-font-color: #8393a7; + --djdt-background-color: #1e293bff; + --djdt-panel-content-background-color: #0f1729ff; + --djdt-panel-title-background-color: #242432; + --djdt-djdt-panel-content-table-strip-background-color: #324154ff; + --djdt--highlighted-background-color: #2c2a7dff; + --djdt-toggle-template-background-color: #282755; + + --djdt-sql-font-color: var(--djdt-font-color); + --djdt-pre-text-color: var(--djdt-font-color); + --djdt-path-and-locals: #65758cff; + --djdt-stack-span-color: #7c8fa4; + --djdt-template-highlight-color: var(--djdt-stack-span-color); + + --djdt-table-border-color: #324154ff; + --djdt-button-border-color: var(--djdt-table-border-color); + --djdt-pre-border-color: var(--djdt-table-border-color); + --djdt-raw-border-color: var(--djdt-table-border-color); +} + /* Debug Toolbar CSS Reset, adapted from Eric Meyer's CSS Reset */ #djDebug { - color: #000; - background: #fff; + color: var(--djdt-font-color); + background: var(--djdt-background-color); } #djDebug, #djDebug div, @@ -87,7 +154,7 @@ outline: 0; font-size: 12px; line-height: 1.5em; - color: #000; + color: var(--djdt-font-color); vertical-align: baseline; background-color: transparent; font-family: var(--djdt-font-family-primary); @@ -100,7 +167,7 @@ #djDebug button { background-color: #eee; background-image: linear-gradient(to bottom, #eee, #cccccc); - border: 1px solid #ccc; + border: 1px solid var(--djdt-button-border-color); border-bottom: 1px solid #bbb; border-radius: 3px; color: #333; @@ -268,10 +335,10 @@ #djDebug pre { white-space: pre-wrap; - color: #555; - border: 1px solid #ccc; + color: var(--djdt-pre-text-color); + border: 1px solid var(--djdt-pre-border-color); border-collapse: collapse; - background-color: #fff; + background-color: var(--djdt-background-color); padding: 2px 3px; margin-bottom: 3px; } @@ -283,7 +350,7 @@ right: 220px; bottom: 0; left: 0px; - background-color: #eee; + background-color: var(--djdt-panel-content-background-color); color: #666; z-index: 100000000; } @@ -294,7 +361,7 @@ #djDebug .djDebugPanelTitle { position: absolute; - background-color: #ffc; + background-color: var(--djdt-panel-title-background-color); color: #666; padding-left: 20px; top: 0; @@ -357,16 +424,16 @@ } #djDebug .djdt-panelContent table { - border: 1px solid #ccc; + border: 1px solid var(--djdt-table-border-color); border-collapse: collapse; width: 100%; - background-color: #fff; + background-color: var(--djdt-panel-content-table-background-color); display: table; margin-top: 0.8em; overflow: auto; } #djDebug .djdt-panelContent tbody > tr:nth-child(odd):not(.djdt-highlighted) { - background-color: #f5f5f5; + background-color: var(--djdt-panel-content-table-strip-background-color); } #djDebug .djdt-panelContent tbody td, #djDebug .djdt-panelContent tbody th { @@ -392,7 +459,7 @@ } #djDebug .djTemplateContext { - background-color: #fff; + background-color: var(--djdt-background-color); } #djDebug .djdt-panelContent .djDebugClose { @@ -433,7 +500,7 @@ #djDebug a.toggleTemplate { padding: 4px; - background-color: #bbb; + background-color: var(--djdt-toggle-template-background-color); border-radius: 3px; } @@ -445,11 +512,11 @@ } #djDebug .djDebugCollapsed { - color: #333; + color: var(--djdt-sql-font-color); } #djDebug .djDebugUncollapsed { - color: #333; + color: var(--djdt-sql-font-color); } #djDebug .djUnselected { @@ -483,66 +550,66 @@ } #djDebug .highlight { - color: #000; + color: var(--djdt-font-color); } #djDebug .highlight .err { - color: #000; + color: var(--djdt-font-color); } /* Error */ #djDebug .highlight .g { - color: #000; + color: var(--djdt-font-color); } /* Generic */ #djDebug .highlight .k { - color: #000; + color: var(--djdt-font-color); font-weight: bold; } /* Keyword */ #djDebug .highlight .o { - color: #000; + color: var(--djdt-font-color); } /* Operator */ #djDebug .highlight .n { - color: #000; + color: var(--djdt-font-color); } /* Name */ #djDebug .highlight .mi { - color: #000; + color: var(--djdt-font-color); font-weight: bold; } /* Literal.Number.Integer */ #djDebug .highlight .l { - color: #000; + color: var(--djdt-font-color); } /* Literal */ #djDebug .highlight .x { - color: #000; + color: var(--djdt-font-color); } /* Other */ #djDebug .highlight .p { - color: #000; + color: var(--djdt-font-color); } /* Punctuation */ #djDebug .highlight .m { - color: #000; + color: var(--djdt-font-color); font-weight: bold; } /* Literal.Number */ #djDebug .highlight .s { - color: #333; + color: var(--djdt-template-highlight-color); } /* Literal.String */ #djDebug .highlight .w { color: #888888; } /* Text.Whitespace */ #djDebug .highlight .il { - color: #000; + color: var(--djdt-font-color); font-weight: bold; } /* Literal.Number.Integer.Long */ #djDebug .highlight .na { - color: #333; + color: var(--djdt-template-highlight-color); } /* Name.Attribute */ #djDebug .highlight .nt { - color: #000; + color: var(--djdt-font-color); font-weight: bold; } /* Name.Tag */ #djDebug .highlight .nv { - color: #333; + color: var(--djdt-template-highlight-color); } /* Name.Variable */ #djDebug .highlight .s2 { - color: #333; + color: var(--djdt-template-highlight-color); } /* Literal.String.Double */ #djDebug .highlight .cp { - color: #333; + color: var(--djdt-template-highlight-color); } /* Comment.Preproc */ #djDebug svg.djDebugLineChart { @@ -595,13 +662,13 @@ } #djDebug .djdt-stack span { - color: #000; + color: var(--djdt-stack-span-color); font-weight: bold; } #djDebug .djdt-stack span.djdt-path, #djDebug .djdt-stack pre.djdt-locals, #djDebug .djdt-stack pre.djdt-locals span { - color: #777; + color: var(--djdt-path-and-locals); font-weight: normal; } #djDebug .djdt-stack span.djdt-code { @@ -612,7 +679,7 @@ } #djDebug .djdt-raw { background-color: #fff; - border: 1px solid #ccc; + border: 1px solid var(--djdt-raw-border-color); margin-top: 0.8em; padding: 5px; white-space: pre-wrap; @@ -631,7 +698,7 @@ max-height: 100%; } #djDebug .djdt-highlighted { - background-color: lightgrey; + background-color: var(--djdt--highlighted-background-color); } #djDebug tr.djdt-highlighted.djdt-profile-row { background-color: #ffc; @@ -654,3 +721,19 @@ .djdt-hidden { display: none; } + +#djDebug #djDebugToolbar a#djToggleThemeButton { + display: flex; + align-items: center; + cursor: pointer; +} +#djToggleThemeButton > svg { + margin-left: auto; +} +#djDebug[data-theme="light"] #djToggleThemeButton svg.theme-light, +#djDebug[data-theme="dark"] #djToggleThemeButton svg.theme-dark, +#djDebug[data-theme="auto"] #djToggleThemeButton svg.theme-auto { + display: block; + height: 1rem; + width: 1rem; +} diff --git a/debug_toolbar/static/debug_toolbar/js/history.js b/debug_toolbar/static/debug_toolbar/js/history.js index b30fcabae..a4287773d 100644 --- a/debug_toolbar/static/debug_toolbar/js/history.js +++ b/debug_toolbar/static/debug_toolbar/js/history.js @@ -25,14 +25,17 @@ function refreshHistory() { const formTarget = djDebug.querySelector(".refreshHistory"); const container = document.getElementById("djdtHistoryRequests"); const oldIds = new Set( - pluckData(container.querySelectorAll("tr[data-store-id]"), "storeId") + pluckData( + container.querySelectorAll("tr[data-request-id]"), + "requestId" + ) ); ajaxForm(formTarget) .then(function (data) { // Remove existing rows first then re-populate with new data container - .querySelectorAll("tr[data-store-id]") + .querySelectorAll("tr[data-request-id]") .forEach(function (node) { node.remove(); }); @@ -43,8 +46,8 @@ function refreshHistory() { .then(function () { const allIds = new Set( pluckData( - container.querySelectorAll("tr[data-store-id]"), - "storeId" + container.querySelectorAll("tr[data-request-id]"), + "requestId" ) ); const newIds = difference(allIds, oldIds); @@ -58,13 +61,13 @@ function refreshHistory() { .then(function (refreshInfo) { refreshInfo.newIds.forEach(function (newId) { const row = container.querySelector( - `tr[data-store-id="${newId}"]` + `tr[data-request-id="${newId}"]` ); row.classList.add("flash-new"); }); setTimeout(() => { container - .querySelectorAll("tr[data-store-id]") + .querySelectorAll("tr[data-request-id]") .forEach((row) => { row.classList.remove("flash-new"); }); @@ -72,9 +75,9 @@ function refreshHistory() { }); } -function switchHistory(newStoreId) { +function switchHistory(newRequestId) { const formTarget = djDebug.querySelector( - ".switchHistory[data-store-id='" + newStoreId + "']" + ".switchHistory[data-request-id='" + newRequestId + "']" ); const tbody = formTarget.closest("tbody"); @@ -88,19 +91,22 @@ function switchHistory(newStoreId) { if (Object.keys(data).length === 0) { const container = document.getElementById("djdtHistoryRequests"); container.querySelector( - 'button[data-store-id="' + newStoreId + '"]' + 'button[data-request-id="' + newRequestId + '"]' ).innerHTML = "Switch [EXPIRED]"; } - replaceToolbarState(newStoreId, data); + replaceToolbarState(newRequestId, data); }); } $$.on(djDebug, "click", ".switchHistory", function (event) { event.preventDefault(); - switchHistory(this.dataset.storeId); + switchHistory(this.dataset.requestId); }); $$.on(djDebug, "click", ".refreshHistory", function (event) { event.preventDefault(); refreshHistory(); }); +// We don't refresh the whole toolbar each fetch or ajax request, +// so we need to refresh the history when we open the panel +$$.onPanelRender(djDebug, "HistoryPanel", refreshHistory); diff --git a/debug_toolbar/static/debug_toolbar/js/toolbar.js b/debug_toolbar/static/debug_toolbar/js/toolbar.js index 9546ef27e..fca9bcf3d 100644 --- a/debug_toolbar/static/debug_toolbar/js/toolbar.js +++ b/debug_toolbar/static/debug_toolbar/js/toolbar.js @@ -1,4 +1,4 @@ -import { $$, ajax, replaceToolbarState, debounce } from "./utils.js"; +import { $$, ajax, debounce, replaceToolbarState } from "./utils.js"; function onKeyDown(event) { if (event.keyCode === 27) { @@ -17,8 +17,10 @@ function getDebugElement() { const djdt = { handleDragged: false, + needUpdateOnFetch: false, init() { const djDebug = getDebugElement(); + djdt.needUpdateOnFetch = djDebug.dataset.updateOnFetch === "True"; $$.on(djDebug, "click", "#djDebugPanelList li a", function (event) { event.preventDefault(); if (!this.className) { @@ -37,13 +39,13 @@ const djdt = { const inner = current.querySelector( ".djDebugPanelContent .djdt-scroll" ), - storeId = djDebug.dataset.storeId; - if (storeId && inner.children.length === 0) { + requestId = djDebug.dataset.requestId; + if (requestId && inner.children.length === 0) { const url = new URL( djDebug.dataset.renderPanelUrl, window.location ); - url.searchParams.append("store_id", storeId); + url.searchParams.append("request_id", requestId); url.searchParams.append("panel_id", panelId); ajax(url).then(function (data) { inner.previousElementSibling.remove(); // Remove AJAX loader @@ -211,6 +213,29 @@ const djdt = { if (djDebug.dataset.sidebarUrl !== undefined) { djdt.updateOnAjax(); } + + // Updates the theme using user settings + const userTheme = localStorage.getItem("djdt.user-theme"); + if (userTheme !== null) { + djDebug.setAttribute("data-theme", userTheme); + } + // Adds the listener to the Theme Toggle Button + $$.on(djDebug, "click", "#djToggleThemeButton", function () { + switch (djDebug.getAttribute("data-theme")) { + case "auto": + djDebug.setAttribute("data-theme", "light"); + localStorage.setItem("djdt.user-theme", "light"); + break; + case "light": + djDebug.setAttribute("data-theme", "dark"); + localStorage.setItem("djdt.user-theme", "dark"); + break; + default: /* dark is the default */ + djDebug.setAttribute("data-theme", "auto"); + localStorage.setItem("djdt.user-theme", "auto"); + break; + } + }); }, hidePanels() { const djDebug = getDebugElement(); @@ -226,7 +251,7 @@ const djdt = { const handle = document.getElementById("djDebugToolbarHandle"); // set handle position const handleTop = Math.min( - localStorage.getItem("djdt.top") || 0, + localStorage.getItem("djdt.top") || 265, window.innerHeight - handle.offsetWidth ); handle.style.top = handleTop + "px"; @@ -270,11 +295,13 @@ const djdt = { document.getElementById("djDebug").dataset.sidebarUrl; const slowjax = debounce(ajax, 200); - function handleAjaxResponse(storeId) { - storeId = encodeURIComponent(storeId); - const dest = `${sidebarUrl}?store_id=${storeId}`; + function handleAjaxResponse(requestId) { + requestId = encodeURIComponent(requestId); + const dest = `${sidebarUrl}?request_id=${requestId}`; slowjax(dest).then(function (data) { - replaceToolbarState(storeId, data); + if (djdt.needUpdateOnFetch) { + replaceToolbarState(requestId, data); + } }); } @@ -286,9 +313,11 @@ const djdt = { // when the header can't be fetched. While it doesn't impede execution // it's worrisome to developers. if ( - this.getAllResponseHeaders().indexOf("djdt-store-id") >= 0 + this.getAllResponseHeaders().indexOf("djdt-request-id") >= 0 ) { - handleAjaxResponse(this.getResponseHeader("djdt-store-id")); + handleAjaxResponse( + this.getResponseHeader("djdt-request-id") + ); } }); origOpen.apply(this, arguments); @@ -298,8 +327,8 @@ const djdt = { window.fetch = function () { const promise = origFetch.apply(this, arguments); promise.then(function (response) { - if (response.headers.get("djdt-store-id") !== null) { - handleAjaxResponse(response.headers.get("djdt-store-id")); + if (response.headers.get("djdt-request-id") !== null) { + handleAjaxResponse(response.headers.get("djdt-request-id")); } // Don't resolve the response via .json(). Instead // continue to return it to allow the caller to consume as needed. diff --git a/debug_toolbar/static/debug_toolbar/js/utils.js b/debug_toolbar/static/debug_toolbar/js/utils.js index b4c7a4cb8..8e300a3a1 100644 --- a/debug_toolbar/static/debug_toolbar/js/utils.js +++ b/debug_toolbar/static/debug_toolbar/js/utils.js @@ -75,7 +75,11 @@ function ajax(url, init) { return fetch(url, init) .then(function (response) { if (response.ok) { - return response.json(); + return response.json().catch(function(error){ + return Promise.reject( + new Error("The response is a invalid Json object : " + error) + ); + }); } return Promise.reject( new Error(response.status + ": " + response.statusText) @@ -105,10 +109,10 @@ function ajaxForm(element) { return ajax(url, ajaxData); } -function replaceToolbarState(newStoreId, data) { +function replaceToolbarState(newRequestId, data) { const djDebug = document.getElementById("djDebug"); - djDebug.setAttribute("data-store-id", newStoreId); - // Check if response is empty, it could be due to an expired storeId. + djDebug.setAttribute("data-request-id", newRequestId); + // Check if response is empty, it could be due to an expired requestId. Object.keys(data).forEach(function (panelId) { const panel = document.getElementById(panelId); if (panel) { diff --git a/debug_toolbar/store.py b/debug_toolbar/store.py index 0bba0c2ef..0bbdbaced 100644 --- a/debug_toolbar/store.py +++ b/debug_toolbar/store.py @@ -5,6 +5,7 @@ from typing import Any, Dict, Iterable from django.core.serializers.json import DjangoJSONEncoder +from django.utils.encoding import force_str from django.utils.module_loading import import_string from debug_toolbar import settings as dt_settings @@ -12,11 +13,20 @@ logger = logging.getLogger(__name__) +class DebugToolbarJSONEncoder(DjangoJSONEncoder): + def default(self, o): + try: + return super().default(o) + except (TypeError, ValueError): + logger.debug("The debug toolbar can't serialize %s into JSON" % o) + return force_str(o) + + def serialize(data: Any) -> str: # If this starts throwing an exceptions, consider # Subclassing DjangoJSONEncoder and using force_str to # make it JSON serializable. - return json.dumps(data, cls=DjangoJSONEncoder) + return json.dumps(data, cls=DebugToolbarJSONEncoder) def deserialize(data: str) -> Any: @@ -24,8 +34,6 @@ def deserialize(data: str) -> Any: class BaseStore: - _config = dt_settings.get_config().copy() - @classmethod def request_ids(cls) -> Iterable: """The stored request ids""" @@ -84,7 +92,9 @@ def set(cls, request_id: str): """Set a request_id in the request store""" if request_id not in cls._request_ids: cls._request_ids.append(request_id) - for _ in range(len(cls._request_ids) - cls._config["RESULTS_CACHE_SIZE"]): + for _ in range( + len(cls._request_ids) - dt_settings.get_config()["RESULTS_CACHE_SIZE"] + ): removed_id = cls._request_ids.popleft() cls._request_store.pop(removed_id, None) @@ -106,14 +116,7 @@ def delete(cls, request_id: str): def save_panel(cls, request_id: str, panel_id: str, data: Any = None): """Save the panel data for the given request_id""" cls.set(request_id) - try: - cls._request_store[request_id][panel_id] = serialize(data) - except TypeError: - if dt_settings.get_config()["SUPPRESS_SERIALIZATION_ERRORS"]: - log = "Panel (%s) failed to serialized data %s properly." - logger.warning(log % (panel_id, data)) - else: - raise + cls._request_store[request_id][panel_id] = serialize(data) @classmethod def panel(cls, request_id: str, panel_id: str) -> Any: @@ -125,6 +128,16 @@ def panel(cls, request_id: str, panel_id: str) -> Any: else: return deserialize(data) + @classmethod + def panels(cls, request_id: str) -> Any: + """Fetch all the panel data for the given request_id""" + try: + panel_mapping = cls._request_store[request_id] + except KeyError: + return {} + for panel, data in panel_mapping.items(): + yield panel, deserialize(data) + -def get_store(): +def get_store() -> BaseStore: return import_string(dt_settings.get_config()["TOOLBAR_STORE_CLASS"]) diff --git a/debug_toolbar/templates/debug_toolbar/base.html b/debug_toolbar/templates/debug_toolbar/base.html index 5447970af..00f5380db 100644 --- a/debug_toolbar/templates/debug_toolbar/base.html +++ b/debug_toolbar/templates/debug_toolbar/base.html @@ -8,7 +8,7 @@ {% endblock %}
+ {{ toolbar.config.ROOT_TAG_EXTRA_ATTRS|safe }} data-update-on-fetch="{{ toolbar.config.UPDATE_ON_FETCH }}" + data-theme="{{ toolbar.config.DEFAULT_THEME }}">
  • {% trans "Hide" %} »
  • +
  • + + {% trans "Toggle Theme" %} {% include "debug_toolbar/includes/theme_selector.html" %} + +
  • {% for panel in toolbar.panels %} {% include "debug_toolbar/includes/panel_button.html" %} {% endfor %} diff --git a/debug_toolbar/templates/debug_toolbar/includes/theme_selector.html b/debug_toolbar/templates/debug_toolbar/includes/theme_selector.html new file mode 100644 index 000000000..926ff250b --- /dev/null +++ b/debug_toolbar/templates/debug_toolbar/includes/theme_selector.html @@ -0,0 +1,41 @@ + + + diff --git a/debug_toolbar/templates/debug_toolbar/panels/alerts.html b/debug_toolbar/templates/debug_toolbar/panels/alerts.html new file mode 100644 index 000000000..df208836d --- /dev/null +++ b/debug_toolbar/templates/debug_toolbar/panels/alerts.html @@ -0,0 +1,12 @@ +{% load i18n %} + +{% if alerts %} +

    {% trans "Alerts found" %}

    + {% for alert in alerts %} +
      +
    • {{ alert.alert }}
    • +
    + {% endfor %} +{% else %} +

    {% trans "No alerts found" %}

    +{% endif %} diff --git a/debug_toolbar/templates/debug_toolbar/panels/history.html b/debug_toolbar/templates/debug_toolbar/panels/history.html index 84c6cb5bd..963eed3fa 100644 --- a/debug_toolbar/templates/debug_toolbar/panels/history.html +++ b/debug_toolbar/templates/debug_toolbar/panels/history.html @@ -1,6 +1,6 @@ {% load i18n %}{% load static %}
    - {{ refresh_form }} + {{ refresh_form.as_div }}
    @@ -15,7 +15,7 @@ - {% for id, store_context in stores.items %} + {% for request_id, history_context in toolbar_history.items %} {% include "debug_toolbar/panels/history_tr.html" %} {% endfor %} diff --git a/debug_toolbar/templates/debug_toolbar/panels/history_tr.html b/debug_toolbar/templates/debug_toolbar/panels/history_tr.html index 31793472a..24b00e11c 100644 --- a/debug_toolbar/templates/debug_toolbar/panels/history_tr.html +++ b/debug_toolbar/templates/debug_toolbar/panels/history_tr.html @@ -1,17 +1,17 @@ {% load i18n %} - +
    - {{ store_context.toolbar.stats.HistoryPanel.time|escape }} + {{ history_context.history_stats.time|escape }} -

    {{ store_context.toolbar.stats.HistoryPanel.request_method|escape }}

    +

    {{ history_context.history_stats.request_method|escape }}

    -

    {{ store_context.toolbar.stats.HistoryPanel.request_url|truncatechars:100|escape }}

    +

    {{ history_context.history_stats.request_url|truncatechars:100|escape }}

    - -
    + +
    @@ -24,7 +24,7 @@ - {% for key, value in store_context.toolbar.stats.HistoryPanel.data.items %} + {% for key, value in history_context.history_stats.data.items %} @@ -39,12 +39,12 @@ diff --git a/debug_toolbar/templates/debug_toolbar/panels/sql.html b/debug_toolbar/templates/debug_toolbar/panels/sql.html index 6080e9f19..e5bf0b7f6 100644 --- a/debug_toolbar/templates/debug_toolbar/panels/sql.html +++ b/debug_toolbar/templates/debug_toolbar/panels/sql.html @@ -77,7 +77,7 @@ {% if query.params %} {% if query.is_select %} - {{ query.form }} + {{ query.form.as_div }} {% if query.vendor == 'mysql' %} diff --git a/debug_toolbar/toolbar.py b/debug_toolbar/toolbar.py index 11f8a1daa..e0773dfec 100644 --- a/debug_toolbar/toolbar.py +++ b/debug_toolbar/toolbar.py @@ -2,28 +2,34 @@ The main DebugToolbar class that loads and renders the Toolbar. """ +import logging +import re import uuid -from collections import OrderedDict from functools import lru_cache from django.apps import apps +from django.conf import settings 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 +from django.urls import include, path, re_path, resolve from django.urls.exceptions import Resolver404 from django.utils.module_loading import import_string from django.utils.translation import get_language, override as lang_override from debug_toolbar import APP_NAME, settings as dt_settings +from debug_toolbar.store import get_store + +logger = logging.getLogger(__name__) class DebugToolbar: # for internal testing use only _created = Signal() + store = None - def __init__(self, request, get_response): + def __init__(self, request, get_response, request_id=None): self.request = request self.config = dt_settings.get_config().copy() panels = [] @@ -33,16 +39,11 @@ 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() - self._panels[panel.panel_id] = panel + self._panels = {panel.panel_id: panel for panel in reversed(panels)} self.stats = {} self.server_timing_stats = {} - self.store_id = None + self.request_id = request_id + self.init_store() self._created.send(request, toolbar=self) # Manage panels @@ -74,7 +75,7 @@ def render_toolbar(self): Renders the overall Toolbar with panels inside. """ if not self.should_render_panels(): - self.store() + self.init_store() try: context = {"toolbar": self} lang = self.config["TOOLBAR_LANGUAGE"] or get_language() @@ -95,31 +96,24 @@ def should_render_panels(self): If False, the panels will be loaded via Ajax. """ - 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 + return self.config["RENDER_PANELS"] or False # Handle storing toolbars in memory and fetching them later on - _store = OrderedDict() + def init_store(self): + # Store already initialized. + if self.store is None: + self.store = get_store() - def store(self): - # Store already exists. - if self.store_id: + if self.request_id: return - self.store_id = uuid.uuid4().hex - self._store[self.store_id] = self - for _ in range(self.config["RESULTS_CACHE_SIZE"], len(self._store)): - self._store.popitem(last=False) + self.request_id = uuid.uuid4().hex + self.store.set(self.request_id) @classmethod - def fetch(cls, store_id): - return cls._store.get(store_id) + def fetch(cls, request_id, panel_id=None): + if get_store().exists(request_id): + return StoredDebugToolbar.from_store(request_id, panel_id=panel_id) # Manually implement class-level caching of panel classes and url patterns # because it's more obvious than going through an abstraction. @@ -185,4 +179,63 @@ def observe_request(request): """ Determine whether to update the toolbar from a client side request. """ - return not DebugToolbar.is_toolbar_request(request) + return True + + +def from_store_get_response(request): + logger.warning( + "get_response was called for debug toolbar after being loaded from the store. No request exists in this scenario as the request is not stored, only the panel's data." + ) + return None + + +class StoredDebugToolbar(DebugToolbar): + def __init__(self, request, get_response, request_id=None): + self.request = None + self.config = dt_settings.get_config().copy() + self.process_request = get_response + self.stats = {} + self.server_timing_stats = {} + self.request_id = request_id + self.init_store() + + @classmethod + def from_store(cls, request_id, panel_id=None): + toolbar = StoredDebugToolbar( + None, from_store_get_response, request_id=request_id + ) + toolbar._panels = {} + + for panel_class in reversed(cls.get_panel_classes()): + panel = panel_class(toolbar, from_store_get_response) + if panel_id and panel.panel_id != panel_id: + continue + data = toolbar.store.panel(toolbar.request_id, panel.panel_id) + if data: + panel.load_stats_from_store(data) + toolbar._panels[panel.panel_id] = panel + return toolbar + + +def debug_toolbar_urls(prefix="__debug__"): + """ + Return a URL pattern for serving toolbar in debug mode. + + from django.conf import settings + from debug_toolbar.toolbar import debug_toolbar_urls + + urlpatterns = [ + # ... the rest of your URLconf goes here ... + ] + debug_toolbar_urls() + """ + if not prefix: + raise ImproperlyConfigured("Empty urls prefix not permitted") + elif not settings.DEBUG: + # No-op if not in debug mode. + return [] + return [ + re_path( + r"^%s/" % re.escape(prefix.lstrip("/")), + include("debug_toolbar.urls"), + ), + ] diff --git a/debug_toolbar/utils.py b/debug_toolbar/utils.py index 7234f1f77..1e75cced2 100644 --- a/debug_toolbar/utils.py +++ b/debug_toolbar/utils.py @@ -162,13 +162,15 @@ def get_template_source_from_exception_info( def get_name_from_obj(obj: Any) -> str: - name = obj.__name__ if hasattr(obj, "__name__") else obj.__class__.__name__ - - if hasattr(obj, "__module__"): - module = obj.__module__ - name = f"{module}.{name}" - - return name + """Get the best name as `str` from a view or a object.""" + # This is essentially a rewrite of the `django.contrib.admindocs.utils.get_view_name` + # https://github.com/django/django/blob/9a22d1769b042a88741f0ff3087f10d94f325d86/django/contrib/admindocs/utils.py#L26-L32 + if hasattr(obj, "view_class"): + klass = obj.view_class + return f"{klass.__module__}.{klass.__qualname__}" + mod_name = obj.__module__ + view_name = getattr(obj, "__qualname__", obj.__class__.__name__) + return mod_name + "." + view_name def getframeinfo(frame: Any, context: int = 1) -> inspect.Traceback: @@ -211,7 +213,7 @@ def getframeinfo(frame: Any, context: int = 1) -> inspect.Traceback: def get_sorted_request_variable( - variable: Union[Dict[str, Any], QueryDict] + variable: Union[Dict[str, Any], QueryDict], ) -> Dict[str, Union[List[Tuple[str, Any]], Any]]: """ Get a data structure for showing a sorted list of variables from the @@ -351,3 +353,16 @@ def get_stack_trace(*, skip=0): def clear_stack_trace_caches(): if hasattr(_local_data, "stack_trace_recorder"): del _local_data.stack_trace_recorder + + +_HTML_TYPES = ("text/html", "application/xhtml+xml") + + +def is_processable_html_response(response): + content_encoding = response.get("Content-Encoding", "") + content_type = response.get("Content-Type", "").split(";")[0] + return ( + not getattr(response, "streaming", False) + and content_encoding == "" + and content_type in _HTML_TYPES + ) diff --git a/debug_toolbar/views.py b/debug_toolbar/views.py index b93acbeed..5d0553f5d 100644 --- a/debug_toolbar/views.py +++ b/debug_toolbar/views.py @@ -10,7 +10,7 @@ @render_with_toolbar_language def render_panel(request): """Render the contents of a panel""" - toolbar = DebugToolbar.fetch(request.GET["store_id"]) + toolbar = DebugToolbar.fetch(request.GET["request_id"], request.GET["panel_id"]) if toolbar is None: content = _( "Data for this panel isn't available anymore. " diff --git a/docs/architecture.rst b/docs/architecture.rst new file mode 100644 index 000000000..7be5ac78d --- /dev/null +++ b/docs/architecture.rst @@ -0,0 +1,84 @@ +Architecture +============ + +The Django Debug Toolbar is designed to be flexible and extensible for +developers and third-party panel creators. + +Core Components +--------------- + +While there are several components, the majority of logic and complexity +lives within the following: + +- ``debug_toolbar.middleware.DebugToolbarMiddleware`` +- ``debug_toolbar.toolbar.DebugToolbar`` +- ``debug_toolbar.panels`` + +^^^^^^^^^^^^^^^^^^^^^^ +DebugToolbarMiddleware +^^^^^^^^^^^^^^^^^^^^^^ + +The middleware is how the toolbar integrates with Django projects. +It determines if the toolbar should instrument the request, which +panels to use, facilitates the processing of the request and augmenting +the response with the toolbar. Most logic for how the toolbar interacts +with the user's Django project belongs here. + +^^^^^^^^^^^^ +DebugToolbar +^^^^^^^^^^^^ + +The ``DebugToolbar`` class orchestrates the processing of a request +for each of the panels. It contains the logic that needs to be aware +of all the panels, but doesn't need to interact with the user's Django +project. + +^^^^^^ +Panels +^^^^^^ + +The majority of the complex logic lives within the panels themselves. This +is because the panels are responsible for collecting the various metrics. +Some of the metrics are collected via +`monkey-patching `_, such as +``TemplatesPanel``. Others, such as ``SettingsPanel`` don't need to collect +anything and include the data directly in the response. + +Some panels such as ``SQLPanel`` have additional functionality. This tends +to involve a user clicking on something, and the toolbar presenting a new +page with additional data. That additional data is handled in views defined +in the panels package (for example, ``debug_toolbar.panels.sql.views``). + +Logic Flow +---------- + +When a request comes in, the toolbar first interacts with it in the +middleware. If the middleware determines the request should be instrumented, +it will instantiate the toolbar and pass the request for processing. The +toolbar will use the enabled panels to collect information on the request +and/or response. When the toolbar has completed collecting its metrics on +both the request and response, the middleware will collect the results +from the toolbar. It will inject the HTML and JavaScript to render the +toolbar as well as any headers into the response. + +After the browser renders the panel and the user interacts with it, the +toolbar's JavaScript will send requests to the server. If the view handling +the request needs to fetch data from the toolbar, the request must supply +the store ID. This is so that the toolbar can load the collected metrics +for that particular request. + +The history panel allows a user to view the metrics for any request since +the application was started. The toolbar maintains its state entirely in +memory for the process running ``runserver``. If the application is +restarted the toolbar will lose its state. + +Problematic Parts +----------------- + +- ``debug.panels.templates.panel``: This monkey-patches template rendering + when the panel module is loaded +- ``debug.panels.sql``: This package is particularly complex, but provides + the main benefit of the toolbar +- Support for async and multi-threading: This is currently unsupported, but + is being implemented as per the + `Async compatible toolbar project `_. diff --git a/docs/changes.rst b/docs/changes.rst index 2dea4306f..00ff50854 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -7,14 +7,130 @@ Serializable (don't include in main) * Defines the ``BaseStore`` interface for request storage mechanisms. * Added the setting ``TOOLBAR_STORE_CLASS`` to configure the request storage mechanism. Defaults to ``debug_toolbar.store.MemoryStore``. -* Added setting ``SUPPRESS_SERIALIZATION_ERRORS`` to suppress - warnings when a ``TypeError`` occurs during a panel's serialization. - +* Rename ``store_id`` properties to ``request_id`` and ``Toolbar.store`` to + ``Toolbar.init_store``. +* Support ``Panel`` instances with stored stats via + ``Panel.load_stats_from_store``. +* Swapped ``Toolbar._store`` for the ``get_store()`` class. +* Created a ``StoredDebugToolbar`` that support creating an instance of the + toolbar representing an old request. It should only be used for fetching + panels' contents. +* Drop ``raw_params`` from query data. +* Queries now have a unique ``djdt_query_id``. The SQL forms now reference + this id and avoid passing SQL to be executed. +* Move the formatting logic of SQL queries to just before rendering in + ``SQLPanel.content``. +* Make ``Panel.panel_id`` a class member. +* Update all panels to utilize data from ``Panel.get_stats()`` to load content + to render. Specifically for ``Panel.title`` and ``Panel.nav_title``. +* Extend example app to contain an async version. Pending ------- +* Changed ordering (and grammatical number) of panels and their titles in + documentation to match actual panel ordering and titles. +* Skipped processing the alerts panel when response isn't a HTML response. + +4.4.5 (2024-07-05) +------------------ + +* Avoided crashing when the alerts panel was skipped. +* Removed the inadvertently added hard dependency on Jinja2. + +4.4.4 (2024-07-05) +------------------ + +* Added check for StreamingHttpResponse in alerts panel. +* Instrument the Django Jinja2 template backend. This only instruments + the immediate template that's rendered. It will not provide stats on + any parent templates. + +4.4.3 (2024-07-04) +------------------ + +* Added alerts panel with warning when form is using file fields + without proper encoding type. +* Fixed overriding font-family for both light and dark themes. +* Restored compatibility with ``iptools.IpRangeList``. +* Limit ``E001`` check to likely error cases when the + ``SHOW_TOOLBAR_CALLBACK`` has changed, but the toolbar's URL + paths aren't installed. +* Introduce helper function ``debug_toolbar_urls`` to + simplify installation. +* Moved "1rem" height/width for SVGs to CSS properties. + +4.4.2 (2024-05-27) +------------------ + +* Removed some CSS which wasn't carefully limited to the toolbar's elements. +* Stopped assuming that ``INTERNAL_IPS`` is a list. +* Added a section to the installation docs about running tests in projects + where the toolbar is being used. + + +4.4.1 (2024-05-26) +------------------ + +* Pin metadata version to 2.2 to be compatible with Jazzband release + process. + +4.4.0 (2024-05-26) +------------------ + +* Raised the minimum Django version to 4.2. +* Automatically support Docker rather than having the developer write a + workaround for ``INTERNAL_IPS``. +* Display a better error message when the toolbar's requests + return invalid json. +* Render forms with ``as_div`` to silence Django 5.0 deprecation warnings. +* Stayed on top of pre-commit hook updates. +* Added :doc:`architecture documentation ` to help + on-board new contributors. +* Removed the static file path validation check in + :class:`StaticFilesPanel ` + since that check is made redundant by a similar check in Django 4.0 and + later. +* Deprecated the ``OBSERVE_REQUEST_CALLBACK`` setting and added check + ``debug_toolbar.W008`` to warn when it is present in + ``DEBUG_TOOLBAR_SETTINGS``. +* Add a note on the profiling panel about using Python 3.12 and later + about needing ``--nothreading`` +* Added ``IS_RUNNING_TESTS`` setting to allow overriding the + ``debug_toolbar.E001`` check to avoid including the toolbar when running + tests. +* Fixed the bug causing ``'djdt' is not a registered namespace`` and updated + docs to help in initial configuration while running tests. +* Added a link in the installation docs to a more complete installation + example in the example app. +* Added check to prevent the toolbar from being installed when tests + are running. +* Added test to example app and command to run the example app's tests. +* Implemented dark mode theme and button to toggle the theme, + introduced the ``DEFAULT_THEME`` setting which sets the default theme + to use. + +4.3.0 (2024-02-01) +------------------ + +* Dropped support for Django 4.0. +* Added Python 3.12 to test matrix. * Removed outdated third-party panels from the list. +* Avoided the unnecessary work of recursively quoting SQL parameters. +* Postponed context process in templates panel to include lazy evaluated + content. +* Fixed template panel to avoid evaluating ``LazyObject`` when not already + evaluated. +* Added support for Django 5.0. +* Refactor the ``utils.get_name_from_obj`` to simulate the behavior of + ``django.contrib.admindocs.utils.get_view_name``. +* Switched from black to the `ruff formatter + `__. +* Changed the default position of the toolbar from top to the upper top + position. +* Added the setting, ``UPDATE_ON_FETCH`` to control whether the + toolbar automatically updates to the latest AJAX request or not. + It defaults to ``False``. 4.2.0 (2023-08-10) ------------------ diff --git a/docs/checks.rst b/docs/checks.rst index 6ed1e88f4..1c41d04fc 100644 --- a/docs/checks.rst +++ b/docs/checks.rst @@ -21,3 +21,6 @@ Django Debug Toolbar setup and configuration: * **debug_toolbar.W007**: JavaScript files are resolving to the wrong content type. Refer to :external:ref:`Django's explanation of mimetypes on Windows `. +* **debug_toolbar.W008**: The deprecated ``OBSERVE_REQUEST_CALLBACK`` setting + is present in ``DEBUG_TOOLBAR_CONFIG``. Use the ``UPDATE_ON_FETCH`` and/or + ``SHOW_TOOLBAR_CALLBACK`` settings instead. diff --git a/docs/conf.py b/docs/conf.py index 7fa8e6fce..8b9d06396 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.2.0" +release = "4.4.5" # -- General configuration --------------------------------------------------- diff --git a/docs/configuration.rst b/docs/configuration.rst index d9d03a853..37155274e 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -29,6 +29,7 @@ default value is:: 'debug_toolbar.panels.sql.SQLPanel', 'debug_toolbar.panels.staticfiles.StaticFilesPanel', 'debug_toolbar.panels.templates.TemplatesPanel', + 'debug_toolbar.panels.alerts.AlertsPanel', 'debug_toolbar.panels.cache.CachePanel', 'debug_toolbar.panels.signals.SignalsPanel', 'debug_toolbar.panels.redirects.RedirectsPanel', @@ -72,6 +73,19 @@ Toolbar options The toolbar searches for this string in the HTML and inserts itself just before. +.. _IS_RUNNING_TESTS: + +* ``IS_RUNNING_TESTS`` + + Default: ``"test" in sys.argv`` + + This setting whether the application is running tests. If this resolves to + ``True``, the toolbar will prevent you from running tests. This should only + be changed if your test command doesn't include ``test`` or if you wish to + test your application with the toolbar configured. If you do wish to test + your application with the toolbar configured, set this setting to + ``False``. + .. _RENDER_PANELS: * ``RENDER_PANELS`` @@ -139,16 +153,30 @@ Toolbar options implication is that it is possible to execute arbitrary SQL through the SQL panel when the ``SECRET_KEY`` value is leaked somehow. + .. warning:: + + Do not use + ``DEBUG_TOOLBAR_CONFIG = {"SHOW_TOOLBAR_CALLBACK": lambda request: DEBUG}`` + in your project's settings.py file. The toolbar expects to use + ``django.conf.settings.DEBUG``. Using your project's setting's ``DEBUG`` + is likely to cause unexpected results when running your tests. This is because + Django automatically sets ``settings.DEBUG = False``, but your project's + setting's ``DEBUG`` will still be set to ``True``. + .. _OBSERVE_REQUEST_CALLBACK: * ``OBSERVE_REQUEST_CALLBACK`` Default: ``'debug_toolbar.toolbar.observe_request'`` + .. note:: + + This setting is deprecated in favor of the ``UPDATE_ON_FETCH`` and + ``SHOW_TOOLBAR_CALLBACK`` settings. + This is the dotted path to a function used for determining whether the - toolbar should update on AJAX requests or not. The default checks are that - the request doesn't originate from the toolbar itself, EG that - ``is_toolbar_request`` is false for a given request. + toolbar should update on AJAX requests or not. The default implementation + always returns ``True``. .. _TOOLBAR_STORE_CLASS: @@ -172,6 +200,24 @@ Toolbar options but want to render your application in French, you would set this to ``"en-us"`` and :setting:`LANGUAGE_CODE` to ``"fr"``. +.. _UPDATE_ON_FETCH: + +* ``UPDATE_ON_FETCH`` + + Default: ``False`` + + This controls whether the toolbar should update to the latest AJAX + request when it occurs. This is especially useful when using htmx + boosting or similar JavaScript techniques. + +.. _DEFAULT_THEME: + +* ``DEFAULT_THEME`` + + Default: ``"auto"`` + + This controls which theme will use the toolbar by default. + Panel options ~~~~~~~~~~~~~ @@ -306,15 +352,6 @@ Panel options the nested functions. The threshold is calculated by the root calls' cumulative time divided by this ratio. -* ``SUPPRESS_SERIALIZATION_ERRORS`` - - Default: ``True`` - - If set to ``True`` then panels will log a warning if a ``TypeError`` is - raised when attempting to serialize a panel's stats rather than raising an - exception.. If set to ``False`` then the ``TypeError`` will be raised. The - default will eventually be set to ``False`` and removed entirely. - * ``SHOW_TEMPLATE_CONTEXT`` Default: ``True`` @@ -359,10 +396,10 @@ Here's what a slightly customized toolbar configuration might look like:: 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: +The debug toolbar uses CSS variables to define fonts and colors. This allows +changing fonts and colors 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 diff --git a/docs/contributing.rst b/docs/contributing.rst index 5e11ee603..0021a88fa 100644 --- a/docs/contributing.rst +++ b/docs/contributing.rst @@ -48,6 +48,12 @@ For convenience, there's an alias for the second command:: Look at ``example/settings.py`` for running the example with another database than SQLite. +Architecture +------------ + +There is high-level information on how the Django Debug Toolbar is structured +in the :doc:`architecture documentation `. + Tests ----- @@ -79,6 +85,12 @@ or by setting the ``DJANGO_SELENIUM_TESTS`` environment variable:: $ DJANGO_SELENIUM_TESTS=true make coverage $ DJANGO_SELENIUM_TESTS=true tox +Note that by default, ``tox`` enables the Selenium tests for a single test +environment. To run the entire ``tox`` test suite with all Selenium tests +disabled, run the following:: + + $ DJANGO_SELENIUM_TESTS= tox + To test via ``tox`` against other databases, you'll need to create the user, database and assign the proper permissions. For PostgreSQL in a ``psql`` shell (note this allows the debug_toolbar user the permission to create diff --git a/docs/index.rst b/docs/index.rst index e53703d4f..e72037045 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -12,3 +12,4 @@ Django Debug Toolbar commands changes contributing + architecture diff --git a/docs/installation.rst b/docs/installation.rst index 3b65ff8e2..9200504b7 100644 --- a/docs/installation.rst +++ b/docs/installation.rst @@ -7,6 +7,10 @@ Process Each of the following steps needs to be configured for the Debug Toolbar to be fully functional. +.. warning:: + + The Debug Toolbar does not currently support `Django's asynchronous views `_. + 1. Install the Package ^^^^^^^^^^^^^^^^^^^^^^ @@ -77,6 +81,11 @@ Add ``"debug_toolbar"`` to your ``INSTALLED_APPS`` setting: "debug_toolbar", # ... ] +.. note:: Check out the configuration example in the + `example app + `_ + to learn how to set up the toolbar to function smoothly while running + your tests. 4. Add the URLs ^^^^^^^^^^^^^^^ @@ -86,14 +95,15 @@ Add django-debug-toolbar's URLs to your project's URLconf: .. code-block:: python from django.urls import include, path + from debug_toolbar.toolbar import debug_toolbar_urls urlpatterns = [ - # ... - path("__debug__/", include("debug_toolbar.urls")), - ] + # ... the rest of your URLconf goes here ... + ] + debug_toolbar_urls() + +By default this uses the ``__debug__`` prefix for the paths, but you can +use any prefix that doesn't clash with your application's URLs. -This example uses the ``__debug__`` prefix, but you can use any prefix that -doesn't clash with your application's URLs. 5. Add the Middleware ^^^^^^^^^^^^^^^^^^^^^ @@ -141,12 +151,45 @@ option. .. warning:: - If using Docker the following will set your ``INTERNAL_IPS`` correctly in Debug mode:: + If using Docker, the toolbar will attempt to look up your host name + automatically and treat it as an allowable internal IP. If you're not + able to get the toolbar to work with your docker installation, review + the code in ``debug_toolbar.middleware.show_toolbar``. + +7. Disable the toolbar when running tests (optional) +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +If you're running tests in your project you shouldn't activate the toolbar. You +can do this by adding another setting: + +.. code-block:: python + + TESTING = "test" in sys.argv + + if not TESTING: + INSTALLED_APPS = [ + *INSTALLED_APPS, + "debug_toolbar", + ] + MIDDLEWARE = [ + "debug_toolbar.middleware.DebugToolbarMiddleware", + *MIDDLEWARE, + ] - if DEBUG: - import socket # only if you haven't already imported this - hostname, _, ips = socket.gethostbyname_ex(socket.gethostname()) - INTERNAL_IPS = [ip[: ip.rfind(".")] + ".1" for ip in ips] + ["127.0.0.1", "10.0.2.2"] +You should also modify your URLconf file: + +.. code-block:: python + + from django.conf import settings + from debug_toolbar.toolbar import debug_toolbar_urls + + if not settings.TESTING: + urlpatterns = [ + *urlpatterns, + ] + debug_toolbar_urls() + +Alternatively, you can check out the :ref:`IS_RUNNING_TESTS ` +option. Troubleshooting --------------- diff --git a/docs/panels.rst b/docs/panels.rst index db4e9311f..7892dcf94 100644 --- a/docs/panels.rst +++ b/docs/panels.rst @@ -22,8 +22,8 @@ snapshot of the toolbar to view that request's stats. ``True`` or if the server runs with multiple processes, the History Panel will be disabled. -Version -~~~~~~~ +Versions +~~~~~~~~ .. class:: debug_toolbar.panels.versions.VersionsPanel @@ -69,19 +69,30 @@ SQL SQL queries including time to execute and links to EXPLAIN each query. -Template -~~~~~~~~ +Static files +~~~~~~~~~~~~ + +.. class:: debug_toolbar.panels.staticfiles.StaticFilesPanel + +Used static files and their locations (via the ``staticfiles`` finders). + +Templates +~~~~~~~~~ .. class:: debug_toolbar.panels.templates.TemplatesPanel Templates and context used, and their template paths. -Static files -~~~~~~~~~~~~ +Alerts +~~~~~~~ -.. class:: debug_toolbar.panels.staticfiles.StaticFilesPanel +.. class:: debug_toolbar.panels.alerts.AlertsPanel -Used static files and their locations (via the ``staticfiles`` finders). +This panel shows alerts for a set of pre-defined cases: + +- Alerts when the response has a form without the + ``enctype="multipart/form-data"`` attribute and the form contains + a file input. Cache ~~~~~ @@ -90,8 +101,8 @@ Cache Cache queries. Is incompatible with Django's per-site caching. -Signal -~~~~~~ +Signals +~~~~~~~ .. class:: debug_toolbar.panels.signals.SignalsPanel @@ -123,6 +134,10 @@ Profiling information for the processing of the request. This panel is included but inactive by default. You can activate it by default with the ``DISABLE_PANELS`` configuration option. +For version of Python 3.12 and later you need to use +``python -m manage runserver --nothreading`` +Concurrent requests don't work with the profiling panel. + The panel will include all function calls made by your project if you're using the setting ``settings.BASE_DIR`` to point to your project's root directory. If a function is in a file within that directory and does not include diff --git a/docs/spelling_wordlist.txt b/docs/spelling_wordlist.txt index 7a15d9aeb..829ff9bec 100644 --- a/docs/spelling_wordlist.txt +++ b/docs/spelling_wordlist.txt @@ -6,6 +6,7 @@ Pympler Roboto Transifex Werkzeug +ajax async backend backends @@ -35,6 +36,7 @@ mousedown mouseup multi neo +nothreading paddings pre profiler @@ -46,6 +48,7 @@ pyupgrade querysets refactoring resizing +runserver spellchecking spooler stacktrace diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 000000000..0b4d0e49e --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,28 @@ +const js = require("@eslint/js"); +const globals = require("globals"); + +module.exports = [ + js.configs.recommended, + { + files: ["**/*.js"], + languageOptions:{ + ecmaVersion: "latest", + sourceType: "module", + globals: { + ...globals.browser, + ...globals.node + } + } + }, + { + rules: { + "curly": ["error", "all"], + "dot-notation": "error", + "eqeqeq": "error", + "no-eval": "error", + "no-var": "error", + "prefer-const": "error", + "semi": "error" + } + } +]; diff --git a/example/asgi.py b/example/asgi.py new file mode 100644 index 000000000..39d4ccb5e --- /dev/null +++ b/example/asgi.py @@ -0,0 +1,16 @@ +""" +ASGI config for example_async project. + +It exposes the ASGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/4.2/howto/deployment/asgi/ +""" + +import os + +from django.core.asgi import get_asgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "example.async_.settings") + +application = get_asgi_application() diff --git a/example/async_/__init__.py b/example/async_/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/example/async_/settings.py b/example/async_/settings.py new file mode 100644 index 000000000..f3bef673a --- /dev/null +++ b/example/async_/settings.py @@ -0,0 +1,5 @@ +"""Django settings for example project.""" + +from ..settings import * # noqa: F403 + +ROOT_URLCONF = "example.async_.urls" diff --git a/example/async_/urls.py b/example/async_/urls.py new file mode 100644 index 000000000..ad19cbc83 --- /dev/null +++ b/example/async_/urls.py @@ -0,0 +1,9 @@ +from django.urls import path + +from example.async_ import views +from example.urls import urlpatterns as sync_urlpatterns + +urlpatterns = [ + path("async/db/", views.async_db_view, name="async_db_view"), + *sync_urlpatterns, +] diff --git a/example/async_/views.py b/example/async_/views.py new file mode 100644 index 000000000..7326e0d0b --- /dev/null +++ b/example/async_/views.py @@ -0,0 +1,9 @@ +from django.contrib.auth.models import User +from django.http import JsonResponse + + +async def async_db_view(request): + names = [] + async for user in User.objects.all(): + names.append(user.username) + return JsonResponse({"names": names}) diff --git a/example/settings.py b/example/settings.py index d2bd57387..d7e10b54a 100644 --- a/example/settings.py +++ b/example/settings.py @@ -1,12 +1,14 @@ """Django settings for example project.""" import os +import sys BASE_DIR = os.path.dirname(os.path.dirname(__file__)) # Quick-start development settings - unsuitable for production + SECRET_KEY = "ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890" DEBUG = True @@ -22,12 +24,11 @@ "django.contrib.sessions", "django.contrib.messages", "django.contrib.staticfiles", - "debug_toolbar", ] MIDDLEWARE = [ - "debug_toolbar.middleware.DebugToolbarMiddleware", "django.middleware.security.SecurityMiddleware", + "whitenoise.middleware.WhiteNoiseMiddleware", "django.contrib.sessions.middleware.SessionMiddleware", "django.middleware.common.CommonMiddleware", "django.middleware.csrf.CsrfViewMiddleware", @@ -41,6 +42,12 @@ STATIC_URL = "/static/" TEMPLATES = [ + { + "NAME": "jinja2", + "BACKEND": "django.template.backends.jinja2.Jinja2", + "APP_DIRS": True, + "DIRS": [os.path.join(BASE_DIR, "example", "templates", "jinja2")], + }, { "BACKEND": "django.template.backends.django.DjangoTemplates", "APP_DIRS": True, @@ -54,14 +61,13 @@ "django.contrib.messages.context_processors.messages", ], }, - } + }, ] USE_TZ = True WSGI_APPLICATION = "example.wsgi.application" -DEBUG_TOOLBAR_CONFIG = {"ROOT_TAG_EXTRA_ATTRS": "data-turbo-permanent hx-preserve"} # Cache and database @@ -97,3 +103,18 @@ } STATICFILES_DIRS = [os.path.join(BASE_DIR, "example", "static")] + + +# Only enable the toolbar when we're in debug mode and we're +# not running tests. Django will change DEBUG to be False for +# tests, so we can't rely on DEBUG alone. +ENABLE_DEBUG_TOOLBAR = DEBUG and "test" not in sys.argv +if ENABLE_DEBUG_TOOLBAR: + INSTALLED_APPS += [ + "debug_toolbar", + ] + MIDDLEWARE += [ + "debug_toolbar.middleware.DebugToolbarMiddleware", + ] + # Customize the config to support turbo and htmx boosting. + DEBUG_TOOLBAR_CONFIG = {"ROOT_TAG_EXTRA_ATTRS": "data-turbo-permanent hx-preserve"} diff --git a/example/templates/bad_form.html b/example/templates/bad_form.html new file mode 100644 index 000000000..f50662c6e --- /dev/null +++ b/example/templates/bad_form.html @@ -0,0 +1,14 @@ +{% load cache %} + + + + + Bad form + + +

    Bad form test

    + + + + + diff --git a/example/templates/index.html b/example/templates/index.html index 382bfb0e9..a10c2b5ac 100644 --- a/example/templates/index.html +++ b/example/templates/index.html @@ -9,11 +9,13 @@

    Index of Tests

    {% cache 10 index_cache %}

    Django Admin

    {% endcache %} @@ -23,9 +25,14 @@

    Index of Tests

    + {% comment %} + + {% endcomment %} + diff --git a/example/templates/jinja2/index.jinja b/example/templates/jinja2/index.jinja new file mode 100644 index 000000000..ffd1ada6f --- /dev/null +++ b/example/templates/jinja2/index.jinja @@ -0,0 +1,12 @@ + + + + + jinja Test + + +

    jinja Test

    + {{ foo }} + {% for i in range(10) %}{{ i }}{% endfor %} {# Jinja2 supports range(), Django templates do not #} + + diff --git a/example/test_views.py b/example/test_views.py new file mode 100644 index 000000000..c3a8b96b0 --- /dev/null +++ b/example/test_views.py @@ -0,0 +1,12 @@ +# Add tests to example app to check how the toolbar is used +# when running tests for a project. +# See https://github.com/jazzband/django-debug-toolbar/issues/1405 + +from django.test import TestCase +from django.urls import reverse + + +class ViewTestCase(TestCase): + def test_index(self): + response = self.client.get(reverse("home")) + assert response.status_code == 200 diff --git a/example/urls.py b/example/urls.py index da52601f8..c5e60c309 100644 --- a/example/urls.py +++ b/example/urls.py @@ -1,11 +1,18 @@ from django.contrib import admin -from django.urls import include, path +from django.urls import path from django.views.generic import TemplateView -from example.views import increment +from debug_toolbar.toolbar import debug_toolbar_urls +from example.views import increment, jinja2_view urlpatterns = [ path("", TemplateView.as_view(template_name="index.html"), name="home"), + path( + "bad-form/", + TemplateView.as_view(template_name="bad_form.html"), + name="bad_form", + ), + path("jinja/", jinja2_view, name="jinja"), path("jquery/", TemplateView.as_view(template_name="jquery/index.html")), path("mootools/", TemplateView.as_view(template_name="mootools/index.html")), path("prototype/", TemplateView.as_view(template_name="prototype/index.html")), @@ -33,5 +40,4 @@ ), path("admin/", admin.site.urls), path("ajax/increment", increment, name="ajax_increment"), - path("__debug__/", include("debug_toolbar.urls")), -] +] + debug_toolbar_urls() diff --git a/example/views.py b/example/views.py index 46136515e..e7e4c1253 100644 --- a/example/views.py +++ b/example/views.py @@ -1,4 +1,5 @@ from django.http import JsonResponse +from django.shortcuts import render def increment(request): @@ -8,3 +9,7 @@ def increment(request): value = 1 request.session["value"] = value return JsonResponse({"value": value}) + + +def jinja2_view(request): + return render(request, "index.jinja", {"foo": "bar"}, using="jinja2") diff --git a/pyproject.toml b/pyproject.toml index 637dada5e..6060a055f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,19 +8,17 @@ requires = [ name = "django-debug-toolbar" description = "A configurable set of panels that display various debug information about the current request/response." readme = "README.rst" -license = {text = "BSD-3-Clause"} +license = { text = "BSD-3-Clause" } authors = [ - { name = "Rob Hudson" }, + { name = "Rob Hudson" }, ] 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", + "Framework :: Django :: 5.0", "Intended Audience :: Developers", "License :: OSI Approved :: BSD License", "Operating System :: OS Independent", @@ -30,83 +28,69 @@ classifiers = [ "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", "Topic :: Software Development :: Libraries :: Python Modules", ] dynamic = [ "version", ] dependencies = [ - "Django>=3.2.4", + "django>=4.2.9", "sqlparse>=0.2", ] -[project.urls] -Download = "https://pypi.org/project/django-debug-toolbar/" -Homepage = "https://github.com/jazzband/django-debug-toolbar" +urls.Download = "https://pypi.org/project/django-debug-toolbar/" +urls.Homepage = "https://github.com/jazzband/django-debug-toolbar" + +[tool.hatch.build.targets.sdist] +# Jazzband's release process is limited to 2.2 metadata +core-metadata-version = "2.2" [tool.hatch.build.targets.wheel] -packages = ["debug_toolbar"] +packages = [ + "debug_toolbar", +] +# Jazzband's release process is limited to 2.2 metadata +core-metadata-version = "2.2" [tool.hatch.version] path = "debug_toolbar/__init__.py" [tool.ruff] -extend-select = [ - # pyflakes, pycodestyle - "F", "E", "W", - # mmcabe - # "C90", - # isort - "I", - # pep8-naming - # "N", - # pyupgrade - "UP", - # flake8-2020 - # "YTT", - # flake8-boolean-trap - "FBT", - # flake8-bugbear - "B", - # flake8-comprehensions - "C4", - # flake8-django - "DJ", - # flake8-pie - "PIE", - # flake8-simplify - "SIM", - # flake8-gettext - "INT", - # pygrep-hooks - "PGH", - # pylint - # "PL", - # unused noqa - "RUF100", -] -extend-ignore = [ - # Allow zip() without strict= - "B905", - # No line length errors - "E501", -] -fix = true -show-fixes = true target-version = "py38" -[tool.ruff.isort] -combine-as-imports = true - -[tool.ruff.mccabe] -max-complexity = 15 - -[tool.ruff.per-file-ignores] -"*/migrat*/*" = [ - # Allow using PascalCase model names in migrations - "N806", - # Ignore the fact that migration files are invalid module names - "N999", +fix = true +show-fixes = true +lint.extend-select = [ + "ASYNC", # flake8-async + "B", # flake8-bugbear + "C4", # flake8-comprehensions + "C90", # McCabe cyclomatic complexity + "DJ", # flake8-django + "E", # pycodestyle errors + "F", # Pyflakes + "FBT", # flake8-boolean-trap + "I", # isort + "INT", # flake8-gettext + "PGH", # pygrep-hooks + "PIE", # flake8-pie + "RUF100", # Unused noqa directive + "SIM", # flake8-simplify + "SLOT", # flake8-slots + "UP", # pyupgrade + "W", # pycodestyle warnings +] +lint.extend-ignore = [ + "B905", # Allow zip() without strict= + "E501", # Ignore line length violations + "SIM108", # Use ternary operator instead of if-else-block + "UP031", # It's not always wrong to use percent-formatting ] +lint.per-file-ignores."*/migrat*/*" = [ + "N806", # Allow using PascalCase model names in migrations + "N999", # Ignore the fact that migration files are invalid module names +] +lint.isort.combine-as-imports = true +lint.mccabe.max-complexity = 16 [tool.coverage.html] skip_covered = true @@ -115,10 +99,15 @@ skip_empty = true [tool.coverage.run] branch = true parallel = true -source = ["debug_toolbar"] +source = [ + "debug_toolbar", +] [tool.coverage.paths] -source = ["src", ".tox/*/site-packages"] +source = [ + "src", + ".tox/*/site-packages", +] [tool.coverage.report] # Update coverage badge link in README.rst when fail_under changes diff --git a/requirements_dev.txt b/requirements_dev.txt index 8b24a8fbb..6baa55cec 100644 --- a/requirements_dev.txt +++ b/requirements_dev.txt @@ -4,6 +4,10 @@ Django sqlparse Jinja2 +# Django Async +daphne +whitenoise # To avoid dealing with static files + # Testing coverage[toml] diff --git a/setup.py b/setup.py index de31ca34f..3893c8d49 100755 --- a/setup.py +++ b/setup.py @@ -2,8 +2,6 @@ import sys -from setuptools import setup - sys.stderr.write( """\ =============================== @@ -14,10 +12,3 @@ """ ) sys.exit(1) - -# The code below will never execute, however is required to -# display the "Used by" section on the GitHub repository. -# -# See: https://github.com/github/feedback/discussions/6456 - -setup(name="django-debug-toolbar") diff --git a/tests/base.py b/tests/base.py index 5cc432add..abdb84008 100644 --- a/tests/base.py +++ b/tests/base.py @@ -3,6 +3,7 @@ from django.http import HttpResponse from django.test import Client, RequestFactory, TestCase, TransactionTestCase +from debug_toolbar.store import get_store from debug_toolbar.toolbar import DebugToolbar @@ -82,6 +83,5 @@ def setUp(self): # The HistoryPanel keeps track of previous stores in memory. # This bleeds into other tests and violates their idempotency. # Clear the store before each test. - for key in list(DebugToolbar._store.keys()): - del DebugToolbar._store[key] + get_store().clear() super().setUp() diff --git a/tests/forms.py b/tests/forms.py index 9a4d38769..916cb6612 100644 --- a/tests/forms.py +++ b/tests/forms.py @@ -6,4 +6,4 @@ class TemplateReprForm(forms.Form): user = forms.ModelChoiceField(queryset=User.objects.all()) def __repr__(self): - return repr(self) + return str(self) diff --git a/tests/panels/test_alerts.py b/tests/panels/test_alerts.py new file mode 100644 index 000000000..40ad8cf67 --- /dev/null +++ b/tests/panels/test_alerts.py @@ -0,0 +1,112 @@ +from django.http import HttpResponse, StreamingHttpResponse +from django.template import Context, Template + +from ..base import BaseTestCase + + +class AlertsPanelTestCase(BaseTestCase): + panel_id = "AlertsPanel" + + def test_alert_warning_display(self): + """ + Test that the panel (does not) display[s] an alert when there are + (no) problems. + """ + self.panel.record_stats({"alerts": []}) + self.assertNotIn("alerts", self.panel.nav_subtitle) + + self.panel.record_stats({"alerts": ["Alert 1", "Alert 2"]}) + self.assertIn("2 alerts", self.panel.nav_subtitle) + + def test_file_form_without_enctype_multipart_form_data(self): + """ + Test that the panel displays a form invalid message when there is + a file input but encoding not set to multipart/form-data. + """ + test_form = '
    ' + result = self.panel.check_invalid_file_form_configuration(test_form) + expected_error = ( + 'Form with id "test-form" contains file input, ' + 'but does not have the attribute enctype="multipart/form-data".' + ) + self.assertEqual(result[0]["alert"], expected_error) + self.assertEqual(len(result), 1) + + def test_file_form_no_id_without_enctype_multipart_form_data(self): + """ + Test that the panel displays a form invalid message when there is + a file input but encoding not set to multipart/form-data. + + This should use the message when the form has no id. + """ + test_form = '
    ' + result = self.panel.check_invalid_file_form_configuration(test_form) + expected_error = ( + "Form contains file input, but does not have " + 'the attribute enctype="multipart/form-data".' + ) + self.assertEqual(result[0]["alert"], expected_error) + self.assertEqual(len(result), 1) + + def test_file_form_with_enctype_multipart_form_data(self): + test_form = """
    + + """ + result = self.panel.check_invalid_file_form_configuration(test_form) + + self.assertEqual(len(result), 0) + + def test_file_form_with_enctype_multipart_form_data_in_button(self): + test_form = """
    + + + """ + result = self.panel.check_invalid_file_form_configuration(test_form) + + self.assertEqual(len(result), 0) + + def test_referenced_file_input_without_enctype_multipart_form_data(self): + test_file_input = """
    + """ + result = self.panel.check_invalid_file_form_configuration(test_file_input) + + expected_error = ( + 'Input element references form with id "test-form", ' + 'but the form does not have the attribute enctype="multipart/form-data".' + ) + self.assertEqual(result[0]["alert"], expected_error) + self.assertEqual(len(result), 1) + + def test_referenced_file_input_with_enctype_multipart_form_data(self): + test_file_input = """
    + + """ + result = self.panel.check_invalid_file_form_configuration(test_file_input) + + self.assertEqual(len(result), 0) + + def test_integration_file_form_without_enctype_multipart_form_data(self): + t = Template('
    ') + c = Context({}) + rendered_template = t.render(c) + response = HttpResponse(content=rendered_template) + + self.panel.generate_stats(self.request, response) + + self.assertIn("1 alert", self.panel.nav_subtitle) + self.assertIn( + "Form with id "test-form" contains file input, " + "but does not have the attribute enctype="multipart/form-data".", + self.panel.content, + ) + + def test_streaming_response(self): + """Test to check for a streaming response.""" + + def _render(): + yield "ok" + + response = StreamingHttpResponse(_render()) + + self.panel.generate_stats(self.request, response) + self.assertEqual(self.panel.get_stats(), {"alerts": []}) diff --git a/tests/panels/test_cache.py b/tests/panels/test_cache.py index aacf521cb..a016f81f0 100644 --- a/tests/panels/test_cache.py +++ b/tests/panels/test_cache.py @@ -1,10 +1,12 @@ from django.core import cache +from debug_toolbar.panels.cache import CachePanel + from ..base import BaseTestCase class CachePanelTestCase(BaseTestCase): - panel_id = "CachePanel" + panel_id = CachePanel.panel_id def test_recording(self): self.assertEqual(len(self.panel.calls), 0) diff --git a/tests/panels/test_history.py b/tests/panels/test_history.py index 2e0aa2179..29e062da0 100644 --- a/tests/panels/test_history.py +++ b/tests/panels/test_history.py @@ -4,6 +4,8 @@ from django.test import RequestFactory, override_settings from django.urls import resolve, reverse +from debug_toolbar.panels.history import HistoryPanel +from debug_toolbar.store import get_store from debug_toolbar.toolbar import DebugToolbar from ..base import BaseTestCase, IntegrationTestCase @@ -12,7 +14,7 @@ class HistoryPanelTestCase(BaseTestCase): - panel_id = "HistoryPanel" + panel_id = HistoryPanel.panel_id def test_disabled(self): config = {"DISABLE_PANELS": {"debug_toolbar.panels.history.HistoryPanel"}} @@ -75,22 +77,24 @@ class HistoryViewsTestCase(IntegrationTestCase): "SQLPanel", "StaticFilesPanel", "TemplatesPanel", + "AlertsPanel", "CachePanel", "SignalsPanel", - "ProfilingPanel", } def test_history_panel_integration_content(self): """Verify the history panel's content renders properly..""" - self.assertEqual(len(DebugToolbar._store), 0) + store = get_store() + self.assertEqual(len(list(store.request_ids())), 0) data = {"foo": "bar"} self.client.get("/json_view/", data, content_type="application/json") # Check the history panel's stats to verify the toolbar rendered properly. - self.assertEqual(len(DebugToolbar._store), 1) - toolbar = list(DebugToolbar._store.values())[0] - content = toolbar.get_panel_by_id("HistoryPanel").content + request_ids = list(store.request_ids()) + self.assertEqual(len(request_ids), 1) + toolbar = DebugToolbar.fetch(request_ids[0]) + content = toolbar.get_panel_by_id(HistoryPanel.panel_id).content self.assertIn("bar", content) self.assertIn('name="exclude_history" value="True"', content) @@ -100,23 +104,28 @@ def test_history_sidebar_invalid(self): def test_history_headers(self): """Validate the headers injected from the history panel.""" + DebugToolbar.get_observe_request.cache_clear() response = self.client.get("/json_view/") - store_id = list(DebugToolbar._store)[0] - self.assertEqual(response.headers["djdt-store-id"], store_id) + request_id = list(get_store().request_ids())[0] + self.assertEqual(response.headers["djdt-request-id"], request_id) - @override_settings( - DEBUG_TOOLBAR_CONFIG={"OBSERVE_REQUEST_CALLBACK": lambda request: False} - ) def test_history_headers_unobserved(self): """Validate the headers aren't injected from the history panel.""" - response = self.client.get("/json_view/") - self.assertNotIn("djdt-store-id", response.headers) + with self.settings( + DEBUG_TOOLBAR_CONFIG={"OBSERVE_REQUEST_CALLBACK": lambda request: False} + ): + DebugToolbar.get_observe_request.cache_clear() + response = self.client.get("/json_view/") + self.assertNotIn("djdt-request-id", response.headers) + # Clear it again to avoid conflicting with another test + # Specifically, DebugToolbarLiveTestCase.test_ajax_refresh + DebugToolbar.get_observe_request.cache_clear() def test_history_sidebar(self): """Validate the history sidebar view.""" self.client.get("/json_view/") - store_id = list(DebugToolbar._store)[0] - data = {"store_id": store_id, "exclude_history": True} + request_id = list(get_store().request_ids())[0] + data = {"request_id": request_id, "exclude_history": True} response = self.client.get(reverse("djdt:history_sidebar"), data=data) self.assertEqual(response.status_code, 200) self.assertEqual( @@ -128,10 +137,9 @@ def test_history_sidebar_includes_history(self): """Validate the history sidebar view.""" self.client.get("/json_view/") panel_keys = copy.copy(self.PANEL_KEYS) - panel_keys.add("HistoryPanel") - panel_keys.add("RedirectsPanel") - store_id = list(DebugToolbar._store)[0] - data = {"store_id": store_id} + panel_keys.add(HistoryPanel.panel_id) + request_id = list(get_store().request_ids())[0] + data = {"request_id": request_id} response = self.client.get(reverse("djdt:history_sidebar"), data=data) self.assertEqual(response.status_code, 200) self.assertEqual( @@ -140,32 +148,34 @@ def test_history_sidebar_includes_history(self): ) @override_settings( - DEBUG_TOOLBAR_CONFIG={"RESULTS_CACHE_SIZE": 1, "RENDER_PANELS": False} + DEBUG_TOOLBAR_CONFIG={"RENDER_PANELS": False, "RESULTS_CACHE_SIZE": 1} ) - def test_history_sidebar_expired_store_id(self): + def test_history_sidebar_expired_request_id(self): """Validate the history sidebar view.""" self.client.get("/json_view/") - store_id = list(DebugToolbar._store)[0] - data = {"store_id": store_id, "exclude_history": True} + request_id = list(get_store().request_ids())[0] + data = {"request_id": request_id, "exclude_history": True} response = self.client.get(reverse("djdt:history_sidebar"), data=data) self.assertEqual(response.status_code, 200) self.assertEqual( set(response.json()), self.PANEL_KEYS, ) + # Make enough requests to unset the original self.client.get("/json_view/") - # Querying old store_id should return in empty response - data = {"store_id": store_id, "exclude_history": True} + # Querying old request_id should return in empty response + data = {"request_id": request_id, "exclude_history": True} response = self.client.get(reverse("djdt:history_sidebar"), data=data) self.assertEqual(response.status_code, 200) self.assertEqual(response.json(), {}) - # Querying with latest store_id - latest_store_id = list(DebugToolbar._store)[0] - data = {"store_id": latest_store_id, "exclude_history": True} + # Querying with latest request_id + latest_request_id = list(get_store().request_ids())[0] + data = {"request_id": latest_request_id, "exclude_history": True} response = self.client.get(reverse("djdt:history_sidebar"), data=data) self.assertEqual(response.status_code, 200) + self.assertEqual( set(response.json()), self.PANEL_KEYS, @@ -179,15 +189,15 @@ def test_history_refresh(self): ) response = self.client.get( - reverse("djdt:history_refresh"), data={"store_id": "foo"} + reverse("djdt:history_refresh"), data={"request_id": "foo"} ) self.assertEqual(response.status_code, 200) data = response.json() self.assertEqual(len(data["requests"]), 2) - store_ids = list(DebugToolbar._store) - self.assertIn(html.escape(store_ids[0]), data["requests"][0]["content"]) - self.assertIn(html.escape(store_ids[1]), data["requests"][1]["content"]) + request_ids = list(get_store().request_ids()) + self.assertIn(html.escape(request_ids[0]), data["requests"][0]["content"]) + self.assertIn(html.escape(request_ids[1]), data["requests"][1]["content"]) for val in ["foo", "bar"]: self.assertIn(val, data["requests"][0]["content"]) diff --git a/tests/panels/test_profiling.py b/tests/panels/test_profiling.py index ff613dfe1..931a5dbf6 100644 --- a/tests/panels/test_profiling.py +++ b/tests/panels/test_profiling.py @@ -1,8 +1,13 @@ +import sys +import unittest + from django.contrib.auth.models import User from django.db import IntegrityError, transaction from django.http import HttpResponse from django.test.utils import override_settings +from debug_toolbar.panels.profiling import ProfilingPanel + from ..base import BaseTestCase, IntegrationTestCase from ..views import listcomp_view, regular_view @@ -11,7 +16,7 @@ DEBUG_TOOLBAR_PANELS=["debug_toolbar.panels.profiling.ProfilingPanel"] ) class ProfilingPanelTestCase(BaseTestCase): - panel_id = "ProfilingPanel" + panel_id = ProfilingPanel.panel_id def test_regular_view(self): self._get_response = lambda request: regular_view(request, "profiling") @@ -50,6 +55,10 @@ def test_cum_time_threshold(self): self.assertNotIn("render", content) self.assertValidHTML(content) + @unittest.skipUnless( + sys.version_info < (3, 12, 0), + "Python 3.12 no longer contains a frame for list comprehensions.", + ) def test_listcomp_escaped(self): self._get_response = lambda request: listcomp_view(request) response = self.panel.process_request(self.request) diff --git a/tests/panels/test_redirects.py b/tests/panels/test_redirects.py index 6b67e6f1d..fb1fb8516 100644 --- a/tests/panels/test_redirects.py +++ b/tests/panels/test_redirects.py @@ -3,11 +3,13 @@ from django.conf import settings from django.http import HttpResponse +from debug_toolbar.panels.redirects import RedirectsPanel + from ..base import BaseTestCase class RedirectsPanelTestCase(BaseTestCase): - panel_id = "RedirectsPanel" + panel_id = RedirectsPanel.panel_id def test_regular_response(self): not_redirect = HttpResponse() diff --git a/tests/panels/test_request.py b/tests/panels/test_request.py index ea7f1681a..6b08404e9 100644 --- a/tests/panels/test_request.py +++ b/tests/panels/test_request.py @@ -1,10 +1,12 @@ from django.http import QueryDict +from debug_toolbar.panels.request import RequestPanel + from ..base import BaseTestCase class RequestPanelTestCase(BaseTestCase): - panel_id = "RequestPanel" + panel_id = RequestPanel.panel_id def test_non_ascii_session(self): self.request.session = {"où": "où"} diff --git a/tests/panels/test_sql.py b/tests/panels/test_sql.py index 932a0dd92..a3379a3d4 100644 --- a/tests/panels/test_sql.py +++ b/tests/panels/test_sql.py @@ -15,6 +15,7 @@ from django.test.utils import override_settings import debug_toolbar.panels.sql.tracking as sql_tracking +from debug_toolbar.panels.sql import SQLPanel try: import psycopg @@ -33,7 +34,7 @@ def sql_call(*, use_iterator=False): class SQLPanelTestCase(BaseTestCase): - panel_id = "SQLPanel" + panel_id = SQLPanel.panel_id def test_disabled(self): config = {"DISABLE_PANELS": {"debug_toolbar.panels.sql.SQLPanel"}} @@ -126,7 +127,7 @@ async def test_cursor_wrapper_asyncio_ctx(self, mock_patch_cursor_wrapper): await sync_to_async(sql_call)() async def task(): - sql_tracking.allow_sql.set(False) # noqa: FBT003 + sql_tracking.allow_sql.set(False) # By disabling sql_tracking.allow_sql, we are indicating that any # future SQL queries should be stopped. If SQL query occurs, # it raises an exception. @@ -311,7 +312,7 @@ def test_binary_param_force_text(self): self.assertIn( "SELECT * FROM" " tests_binary WHERE field =", - self.panel._queries[0]["sql"], + self.panel.content, ) @unittest.skipUnless(connection.vendor != "sqlite", "Test invalid for SQLite") @@ -383,8 +384,6 @@ def test_insert_content(self): """ list(User.objects.filter(username="café")) response = self.panel.process_request(self.request) - # ensure the panel does not have content yet. - self.assertNotIn("café", self.panel.content) self.panel.generate_stats(self.request, response) # ensure the panel renders correctly. content = self.panel.content @@ -513,20 +512,29 @@ def test_prettify_sql(self): list(User.objects.filter(username__istartswith="spam")) response = self.panel.process_request(self.request) self.panel.generate_stats(self.request, response) + # The content formats the sql and prettifies it + self.assertTrue(self.panel.content) pretty_sql = self.panel._queries[-1]["sql"] self.assertEqual(len(self.panel._queries), 1) - # Reset the queries - self.panel._queries = [] + # Recreate the panel to reset the queries. Content being a cached_property + # which doesn't have a way to reset it. + self.panel.disable_instrumentation() + self.panel = SQLPanel(self.panel.toolbar, self.panel.get_response) + self.panel.enable_instrumentation() # Run it again, but with prettify off. Verify that it's different. 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) + # The content formats the sql and prettifies it + self.assertTrue(self.panel.content) self.assertEqual(len(self.panel._queries), 1) - self.assertNotEqual(pretty_sql, self.panel._queries[-1]["sql"]) + self.assertNotIn(pretty_sql, self.panel.content) - self.panel._queries = [] + self.panel.disable_instrumentation() + self.panel = SQLPanel(self.panel.toolbar, self.panel.get_response) + self.panel.enable_instrumentation() # 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. @@ -534,8 +542,10 @@ def test_prettify_sql(self): list(User.objects.filter(username__istartswith="spam")) response = self.panel.process_request(self.request) self.panel.generate_stats(self.request, response) + # The content formats the sql and prettifies it + self.assertTrue(self.panel.content) self.assertEqual(len(self.panel._queries), 1) - self.assertEqual(pretty_sql, self.panel._queries[-1]["sql"]) + self.assertIn(pretty_sql, self.panel.content) def test_simplification(self): """ @@ -547,6 +557,8 @@ def test_simplification(self): list(User.objects.values_list("id")) response = self.panel.process_request(self.request) self.panel.generate_stats(self.request, response) + # The content formats the sql which injects the ellipsis character + self.assertTrue(self.panel.content) self.assertEqual(len(self.panel._queries), 3) self.assertNotIn("\u2022", self.panel._queries[0]["sql"]) self.assertNotIn("\u2022", self.panel._queries[1]["sql"]) @@ -572,6 +584,8 @@ def test_top_level_simplification(self): ) response = self.panel.process_request(self.request) self.panel.generate_stats(self.request, response) + # The content formats the sql which injects the ellipsis character + self.assertTrue(self.panel.content) if connection.vendor != "mysql": self.assertEqual(len(self.panel._queries), 4) else: @@ -685,7 +699,7 @@ def test_similar_and_duplicate_grouping(self): class SQLPanelMultiDBTestCase(BaseMultiDBTestCase): - panel_id = "SQLPanel" + panel_id = SQLPanel.panel_id def test_aliases(self): self.assertFalse(self.panel._queries) diff --git a/tests/panels/test_staticfiles.py b/tests/panels/test_staticfiles.py index 32ed7ea61..2715ee1af 100644 --- a/tests/panels/test_staticfiles.py +++ b/tests/panels/test_staticfiles.py @@ -1,18 +1,13 @@ -import os -import unittest - -import django from django.conf import settings from django.contrib.staticfiles import finders -from django.test.utils import override_settings -from ..base import BaseTestCase +from debug_toolbar.panels.staticfiles import StaticFilesPanel -PATH_DOES_NOT_EXIST = os.path.join(settings.BASE_DIR, "tests", "invalid_static") +from ..base import BaseTestCase class StaticFilesPanelTestCase(BaseTestCase): - panel_id = "StaticFilesPanel" + panel_id = StaticFilesPanel.panel_id def test_default_case(self): response = self.panel.process_request(self.request) @@ -24,7 +19,7 @@ def test_default_case(self): self.assertIn( "django.contrib.staticfiles.finders.FileSystemFinder (2 files)", content ) - self.assertEqual(self.panel.num_used, 0) + self.assertEqual(self.panel.get_stats()["num_used"], 0) self.assertNotEqual(self.panel.num_found, 0) expected_apps = ["django.contrib.admin", "debug_toolbar"] if settings.USE_GIS: @@ -52,33 +47,3 @@ def test_insert_content(self): "django.contrib.staticfiles.finders.AppDirectoriesFinder", content ) self.assertValidHTML(content) - - @unittest.skipIf(django.VERSION >= (4,), "Django>=4 handles missing dirs itself.") - @override_settings( - STATICFILES_DIRS=[PATH_DOES_NOT_EXIST] + settings.STATICFILES_DIRS, - STATIC_ROOT=PATH_DOES_NOT_EXIST, - ) - def test_finder_directory_does_not_exist(self): - """Misconfigure the static files settings and verify the toolbar runs. - - The test case is that the STATIC_ROOT is in STATICFILES_DIRS and that - the directory of STATIC_ROOT does not exist. - """ - response = self.panel.process_request(self.request) - self.panel.generate_stats(self.request, response) - content = self.panel.content - self.assertIn( - "django.contrib.staticfiles.finders.AppDirectoriesFinder", content - ) - self.assertNotIn( - "django.contrib.staticfiles.finders.FileSystemFinder (2 files)", content - ) - self.assertEqual(self.panel.num_used, 0) - self.assertNotEqual(self.panel.num_found, 0) - expected_apps = ["django.contrib.admin", "debug_toolbar"] - if settings.USE_GIS: - expected_apps = ["django.contrib.gis"] + expected_apps - self.assertEqual(self.panel.get_staticfiles_apps(), expected_apps) - self.assertEqual( - self.panel.get_staticfiles_dirs(), finders.FileSystemFinder().locations - ) diff --git a/tests/panels/test_template.py b/tests/panels/test_template.py index 37e70cfa5..b90e3afd1 100644 --- a/tests/panels/test_template.py +++ b/tests/panels/test_template.py @@ -1,7 +1,13 @@ +from unittest import expectedFailure + import django from django.contrib.auth.models import User from django.template import Context, RequestContext, Template from django.test import override_settings +from django.utils.functional import SimpleLazyObject + +from debug_toolbar.panels.sql import SQLPanel +from debug_toolbar.panels.templates import TemplatesPanel from ..base import BaseTestCase, IntegrationTestCase from ..forms import TemplateReprForm @@ -9,11 +15,11 @@ class TemplatesPanelTestCase(BaseTestCase): - panel_id = "TemplatesPanel" + panel_id = TemplatesPanel.panel_id def setUp(self): super().setUp() - self.sql_panel = self.toolbar.get_panel_by_id("SQLPanel") + self.sql_panel = self.toolbar.get_panel_by_id(SQLPanel.panel_id) self.sql_panel.enable_instrumentation() def tearDown(self): @@ -21,6 +27,7 @@ def tearDown(self): super().tearDown() def test_queryset_hook(self): + response = self.panel.process_request(self.request) t = Template("No context variables here!") c = Context( { @@ -29,12 +36,13 @@ def test_queryset_hook(self): } ) t.render(c) + self.panel.generate_stats(self.request, response) # ensure the query was NOT logged self.assertEqual(len(self.sql_panel._queries), 0) self.assertEqual( - self.panel.templates[0]["context"], + self.panel.templates[0]["context_list"], [ "{'False': False, 'None': None, 'True': True}", "{'deep_queryset': '<>',\n" @@ -99,26 +107,57 @@ def test_disabled(self): self.assertFalse(self.panel.enabled) def test_empty_context(self): + response = self.panel.process_request(self.request) t = Template("") c = Context({}) t.render(c) + self.panel.generate_stats(self.request, response) # Includes the builtin context but not the empty one. self.assertEqual( - self.panel.templates[0]["context"], + self.panel.templates[0]["context_list"], ["{'False': False, 'None': None, 'True': True}"], ) + def test_lazyobject(self): + response = self.panel.process_request(self.request) + t = Template("") + c = Context({"lazy": SimpleLazyObject(lambda: "lazy_value")}) + t.render(c) + self.panel.generate_stats(self.request, response) + self.assertNotIn("lazy_value", self.panel.content) + + def test_lazyobject_eval(self): + response = self.panel.process_request(self.request) + t = Template("{{lazy}}") + c = Context({"lazy": SimpleLazyObject(lambda: "lazy_value")}) + self.assertEqual(t.render(c), "lazy_value") + self.panel.generate_stats(self.request, response) + self.assertIn("lazy_value", self.panel.content) + @override_settings( DEBUG=True, DEBUG_TOOLBAR_PANELS=["debug_toolbar.panels.templates.TemplatesPanel"] ) class JinjaTemplateTestCase(IntegrationTestCase): def test_django_jinja2(self): + r = self.client.get("/regular_jinja/foobar/") + self.assertContains(r, "Test for foobar (Jinja)") + # This should be 2 templates because of the parent template. + # See test_django_jinja2_parent_template_instrumented + self.assertContains(r, "

    Templates (1 rendered)

    ") + self.assertContains(r, "basic.jinja") + + @expectedFailure + def test_django_jinja2_parent_template_instrumented(self): + """ + When Jinja2 templates are properly instrumented, the + parent template should be instrumented. + """ r = self.client.get("/regular_jinja/foobar/") self.assertContains(r, "Test for foobar (Jinja)") self.assertContains(r, "

    Templates (2 rendered)

    ") - self.assertContains(r, "jinja2/basic.jinja") + self.assertContains(r, "basic.jinja") def context_processor(request): diff --git a/tests/panels/test_versions.py b/tests/panels/test_versions.py index 27ccba92b..b484c043a 100644 --- a/tests/panels/test_versions.py +++ b/tests/panels/test_versions.py @@ -1,5 +1,7 @@ from collections import namedtuple +from debug_toolbar.panels.versions import VersionsPanel + from ..base import BaseTestCase version_info_t = namedtuple( @@ -8,7 +10,7 @@ class VersionsPanelTestCase(BaseTestCase): - panel_id = "VersionsPanel" + panel_id = VersionsPanel.panel_id def test_app_version_from_get_version_fn(self): class FakeApp: diff --git a/tests/settings.py b/tests/settings.py index b3c281242..77f11e73a 100644 --- a/tests/settings.py +++ b/tests/settings.py @@ -126,5 +126,8 @@ DEBUG_TOOLBAR_CONFIG = { # Django's test client sets wsgi.multiprocess to True inappropriately - "RENDER_PANELS": False + "RENDER_PANELS": False, + "RESULTS_CACHE_SIZE": 3, + # IS_RUNNING_TESTS must be False even though we're running tests because we're running the toolbar's own tests. + "IS_RUNNING_TESTS": False, } diff --git a/tests/sync.py b/tests/sync.py index d71298089..d7a9872fd 100644 --- a/tests/sync.py +++ b/tests/sync.py @@ -1,6 +1,7 @@ """ Taken from channels.db """ + from asgiref.sync import SyncToAsync from django.db import close_old_connections diff --git a/tests/templates/ajax/ajax.html b/tests/templates/ajax/ajax.html new file mode 100644 index 000000000..c9de3acb6 --- /dev/null +++ b/tests/templates/ajax/ajax.html @@ -0,0 +1,21 @@ +{% extends "base.html" %} +{% block content %} +
    click for ajax
    + + +{% endblock %} diff --git a/tests/templates/jinja2/base.html b/tests/templates/jinja2/base.html new file mode 100644 index 000000000..ea0d773ac --- /dev/null +++ b/tests/templates/jinja2/base.html @@ -0,0 +1,9 @@ + + + + {{ title }} + + + {% block content %}{% endblock %} + + diff --git a/tests/templates/jinja2/basic.jinja b/tests/templates/jinja2/basic.jinja index 812acbcac..e531eee64 100644 --- a/tests/templates/jinja2/basic.jinja +++ b/tests/templates/jinja2/basic.jinja @@ -1,2 +1,5 @@ {% extends 'base.html' %} -{% block content %}Test for {{ title }} (Jinja){% endblock %} +{% block content %} +Test for {{ title }} (Jinja) +{% for i in range(10) %}{{ i }}{% endfor %} {# Jinja2 supports range(), Django templates do not #} +{% endblock %} diff --git a/tests/test_checks.py b/tests/test_checks.py index 8e4f8e62f..27db92a9d 100644 --- a/tests/test_checks.py +++ b/tests/test_checks.py @@ -1,13 +1,10 @@ -import os -import unittest from unittest.mock import patch -import django -from django.conf import settings from django.core.checks import Warning, run_checks from django.test import SimpleTestCase, override_settings +from django.urls import NoReverseMatch -PATH_DOES_NOT_EXIST = os.path.join(settings.BASE_DIR, "tests", "invalid_static") +from debug_toolbar.apps import debug_toolbar_installed_when_running_tests_check class ChecksTestCase(SimpleTestCase): @@ -92,23 +89,6 @@ def test_check_middleware_classes_error(self): messages, ) - @unittest.skipIf(django.VERSION >= (4,), "Django>=4 handles missing dirs itself.") - @override_settings( - STATICFILES_DIRS=[PATH_DOES_NOT_EXIST], - ) - def test_panel_check_errors(self): - messages = run_checks() - self.assertEqual( - messages, - [ - Warning( - "debug_toolbar requires the STATICFILES_DIRS directories to exist.", - hint="Running manage.py collectstatic may help uncover the issue.", - id="debug_toolbar.staticfiles.W001", - ) - ], - ) - @override_settings(DEBUG_TOOLBAR_PANELS=[]) def test_panels_is_empty(self): errors = run_checks() @@ -120,7 +100,7 @@ def test_panels_is_empty(self): hint="Set DEBUG_TOOLBAR_PANELS to a non-empty list in your " "settings.py.", id="debug_toolbar.W005", - ) + ), ], ) @@ -258,3 +238,79 @@ def test_check_w007_invalid(self, mocked_guess_type): ) ], ) + + @patch("debug_toolbar.apps.reverse") + def test_debug_toolbar_installed_when_running_tests(self, reverse): + params = [ + { + "debug": True, + "running_tests": True, + "show_callback_changed": True, + "urls_installed": False, + "errors": False, + }, + { + "debug": False, + "running_tests": False, + "show_callback_changed": True, + "urls_installed": False, + "errors": False, + }, + { + "debug": False, + "running_tests": True, + "show_callback_changed": False, + "urls_installed": False, + "errors": False, + }, + { + "debug": False, + "running_tests": True, + "show_callback_changed": True, + "urls_installed": True, + "errors": False, + }, + { + "debug": False, + "running_tests": True, + "show_callback_changed": True, + "urls_installed": False, + "errors": True, + }, + ] + for config in params: + with self.subTest(**config): + config_setting = { + "RENDER_PANELS": False, + "IS_RUNNING_TESTS": config["running_tests"], + "SHOW_TOOLBAR_CALLBACK": ( + (lambda *args: True) + if config["show_callback_changed"] + else "debug_toolbar.middleware.show_toolbar" + ), + } + if config["urls_installed"]: + reverse.side_effect = lambda *args: None + else: + reverse.side_effect = NoReverseMatch() + + with self.settings( + DEBUG=config["debug"], DEBUG_TOOLBAR_CONFIG=config_setting + ): + errors = debug_toolbar_installed_when_running_tests_check(None) + if config["errors"]: + self.assertEqual(len(errors), 1) + self.assertEqual(errors[0].id, "debug_toolbar.E001") + else: + self.assertEqual(len(errors), 0) + + @override_settings( + DEBUG_TOOLBAR_CONFIG={ + "OBSERVE_REQUEST_CALLBACK": lambda request: False, + "IS_RUNNING_TESTS": False, + } + ) + def test_observe_request_callback_specified(self): + errors = run_checks() + self.assertEqual(len(errors), 1) + self.assertEqual(errors[0].id, "debug_toolbar.W008") diff --git a/tests/test_integration.py b/tests/test_integration.py index b77b7cede..245239918 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -1,6 +1,8 @@ import os import re +import time import unittest +from unittest.mock import patch import html5lib from django.contrib.staticfiles.testing import StaticLiveServerTestCase @@ -15,6 +17,13 @@ from debug_toolbar.forms import SignedDataForm from debug_toolbar.middleware import DebugToolbarMiddleware, show_toolbar from debug_toolbar.panels import Panel +from debug_toolbar.panels.cache import CachePanel +from debug_toolbar.panels.history import HistoryPanel +from debug_toolbar.panels.request import RequestPanel +from debug_toolbar.panels.sql import SQLPanel +from debug_toolbar.panels.templates import TemplatesPanel +from debug_toolbar.panels.versions import VersionsPanel +from debug_toolbar.store import get_store from debug_toolbar.toolbar import DebugToolbar from .base import BaseTestCase, IntegrationTestCase @@ -34,13 +43,13 @@ rf = RequestFactory() -def toolbar_store_id(): +def toolbar_request_id(): def get_response(request): return HttpResponse() toolbar = DebugToolbar(rf.get("/"), get_response) - toolbar.store() - return toolbar.store_id + toolbar.init_store() + return toolbar.request_id class BuggyPanel(Panel): @@ -65,6 +74,36 @@ def test_show_toolbar_INTERNAL_IPS(self): with self.settings(INTERNAL_IPS=[]): self.assertFalse(show_toolbar(self.request)) + @patch("socket.gethostbyname", return_value="127.0.0.255") + def test_show_toolbar_docker(self, mocked_gethostbyname): + with self.settings(INTERNAL_IPS=[]): + # Is true because REMOTE_ADDR is 127.0.0.1 and the 255 + # is shifted to be 1. + self.assertTrue(show_toolbar(self.request)) + mocked_gethostbyname.assert_called_once_with("host.docker.internal") + + def test_not_iterating_over_INTERNAL_IPS(self): + """Verify that the middleware does not iterate over INTERNAL_IPS in some way. + + Some people use iptools.IpRangeList for their INTERNAL_IPS. This is a class + that can quickly answer the question if the setting contain a certain IP address, + but iterating over this object will drain all performance / blow up. + """ + + class FailOnIteration: + def __iter__(self): + raise RuntimeError( + "The testcase failed: the code should not have iterated over INTERNAL_IPS" + ) + + def __contains__(self, x): + return True + + with self.settings(INTERNAL_IPS=FailOnIteration()): + response = self.client.get("/regular/basic/") + self.assertEqual(response.status_code, 200) + self.assertContains(response, "djDebug") # toolbar + def test_should_render_panels_RENDER_PANELS(self): """ The toolbar should force rendering panels on each request @@ -75,29 +114,12 @@ def test_should_render_panels_RENDER_PANELS(self): 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 - panel = self.toolbar.get_panel_by_id("RequestPanel") + panel = self.toolbar.get_panel_by_id(RequestPanel.panel_id) response = panel.process_request(self.request) panel.generate_stats(self.request, response) return panel.get_stats() @@ -148,9 +170,13 @@ def test_cache_page(self): # may run earlier and cause fewer cache calls. cache.clear() response = self.client.get("/cached_view/") - self.assertEqual(len(response.toolbar.get_panel_by_id("CachePanel").calls), 3) + self.assertEqual( + len(response.toolbar.get_panel_by_id(CachePanel.panel_id).calls), 3 + ) response = self.client.get("/cached_view/") - self.assertEqual(len(response.toolbar.get_panel_by_id("CachePanel").calls), 2) + self.assertEqual( + len(response.toolbar.get_panel_by_id(CachePanel.panel_id).calls), 2 + ) @override_settings(ROOT_URLCONF="tests.urls_use_package_urls") def test_include_package_urls(self): @@ -159,16 +185,24 @@ def test_include_package_urls(self): # may run earlier and cause fewer cache calls. cache.clear() response = self.client.get("/cached_view/") - self.assertEqual(len(response.toolbar.get_panel_by_id("CachePanel").calls), 3) + self.assertEqual( + len(response.toolbar.get_panel_by_id(CachePanel.panel_id).calls), 3 + ) response = self.client.get("/cached_view/") - self.assertEqual(len(response.toolbar.get_panel_by_id("CachePanel").calls), 2) + self.assertEqual( + len(response.toolbar.get_panel_by_id(CachePanel.panel_id).calls), 2 + ) def test_low_level_cache_view(self): """Test cases when low level caching API is used within a request.""" response = self.client.get("/cached_low_level_view/") - self.assertEqual(len(response.toolbar.get_panel_by_id("CachePanel").calls), 2) + self.assertEqual( + len(response.toolbar.get_panel_by_id(CachePanel.panel_id).calls), 2 + ) response = self.client.get("/cached_low_level_view/") - self.assertEqual(len(response.toolbar.get_panel_by_id("CachePanel").calls), 1) + self.assertEqual( + len(response.toolbar.get_panel_by_id(CachePanel.panel_id).calls), 1 + ) def test_cache_disable_instrumentation(self): """ @@ -180,7 +214,9 @@ def test_cache_disable_instrumentation(self): response = self.client.get("/execute_sql/") self.assertEqual(cache.get("UseCacheAfterToolbar.before"), 1) self.assertEqual(cache.get("UseCacheAfterToolbar.after"), 1) - self.assertEqual(len(response.toolbar.get_panel_by_id("CachePanel").calls), 0) + self.assertEqual( + len(response.toolbar.get_panel_by_id(CachePanel.panel_id).calls), 0 + ) def test_is_toolbar_request(self): self.request.path = "/__debug__/render_panel/" @@ -214,7 +250,7 @@ def test_is_toolbar_request_override_request_urlconf(self): def test_data_gone(self): response = self.client.get( - "/__debug__/render_panel/?store_id=GONE&panel_id=RequestPanel" + "/__debug__/render_panel/?request_id=GONE&panel_id=RequestPanel" ) self.assertIn("Please reload the page and retry.", response.json()["content"]) @@ -252,34 +288,42 @@ def test_html5_validation(self): def test_render_panel_checks_show_toolbar(self): url = "/__debug__/render_panel/" - data = {"store_id": toolbar_store_id(), "panel_id": "VersionsPanel"} + request_id = toolbar_request_id() + get_store().save_panel( + request_id, VersionsPanel.panel_id, {"value": "Test data"} + ) + data = {"request_id": request_id, "panel_id": VersionsPanel.panel_id} response = self.client.get(url, data) self.assertEqual(response.status_code, 200) - response = self.client.get(url, data, HTTP_X_REQUESTED_WITH="XMLHttpRequest") + response = self.client.get( + url, data, headers={"x-requested-with": "XMLHttpRequest"} + ) self.assertEqual(response.status_code, 200) with self.settings(INTERNAL_IPS=[]): response = self.client.get(url, data) self.assertEqual(response.status_code, 404) response = self.client.get( - url, data, HTTP_X_REQUESTED_WITH="XMLHttpRequest" + url, data, headers={"x-requested-with": "XMLHttpRequest"} ) self.assertEqual(response.status_code, 404) def test_middleware_render_toolbar_json(self): """Verify the toolbar is rendered and data is stored for a json request.""" - self.assertEqual(len(DebugToolbar._store), 0) + store = get_store() + self.assertEqual(len(list(store.request_ids())), 0) data = {"foo": "bar"} response = self.client.get("/json_view/", data, content_type="application/json") self.assertEqual(response.status_code, 200) self.assertEqual(response.content.decode("utf-8"), '{"foo": "bar"}') # Check the history panel's stats to verify the toolbar rendered properly. - self.assertEqual(len(DebugToolbar._store), 1) - toolbar = list(DebugToolbar._store.values())[0] + request_ids = list(store.request_ids()) + self.assertEqual(len(request_ids), 1) + toolbar = DebugToolbar.fetch(request_ids[0]) self.assertEqual( - toolbar.get_panel_by_id("HistoryPanel").get_stats()["data"], - {"foo": ["bar"]}, + toolbar.get_panel_by_id(HistoryPanel.panel_id).get_stats()["data"], + {"foo": "bar"}, ) def test_template_source_checks_show_toolbar(self): @@ -292,13 +336,15 @@ def test_template_source_checks_show_toolbar(self): response = self.client.get(url, data) self.assertEqual(response.status_code, 200) - response = self.client.get(url, data, HTTP_X_REQUESTED_WITH="XMLHttpRequest") + response = self.client.get( + url, data, headers={"x-requested-with": "XMLHttpRequest"} + ) self.assertEqual(response.status_code, 200) with self.settings(INTERNAL_IPS=[]): response = self.client.get(url, data) self.assertEqual(response.status_code, 404) response = self.client.get( - url, data, HTTP_X_REQUESTED_WITH="XMLHttpRequest" + url, data, headers={"x-requested-with": "XMLHttpRequest"} ) self.assertEqual(response.status_code, 404) @@ -323,54 +369,66 @@ def test_template_source_errors(self): self.assertContains(response, "Template Does Not Exist: does_not_exist.html") def test_sql_select_checks_show_toolbar(self): + self.client.get("/execute_sql/") + request_ids = list(get_store().request_ids()) + request_id = request_ids[-1] + toolbar = DebugToolbar.fetch(request_id, SQLPanel.panel_id) + panel = toolbar.get_panel_by_id(SQLPanel.panel_id) + djdt_query_id = panel.get_stats()["queries"][-1]["djdt_query_id"] + url = "/__debug__/sql_select/" data = { "signed": SignedDataForm.sign( { - "sql": "SELECT * FROM auth_user", - "raw_sql": "SELECT * FROM auth_user", - "params": "{}", - "alias": "default", - "duration": "0", + "request_id": request_id, + "djdt_query_id": djdt_query_id, } ) } response = self.client.post(url, data) self.assertEqual(response.status_code, 200) - response = self.client.post(url, data, HTTP_X_REQUESTED_WITH="XMLHttpRequest") + response = self.client.post( + url, data, headers={"x-requested-with": "XMLHttpRequest"} + ) self.assertEqual(response.status_code, 200) with self.settings(INTERNAL_IPS=[]): response = self.client.post(url, data) self.assertEqual(response.status_code, 404) response = self.client.post( - url, data, HTTP_X_REQUESTED_WITH="XMLHttpRequest" + url, data, headers={"x-requested-with": "XMLHttpRequest"} ) self.assertEqual(response.status_code, 404) def test_sql_explain_checks_show_toolbar(self): + self.client.get("/execute_sql/") + request_ids = list(get_store().request_ids()) + request_id = request_ids[-1] + toolbar = DebugToolbar.fetch(request_id, SQLPanel.panel_id) + panel = toolbar.get_panel_by_id(SQLPanel.panel_id) + djdt_query_id = panel.get_stats()["queries"][-1]["djdt_query_id"] + url = "/__debug__/sql_explain/" data = { "signed": SignedDataForm.sign( { - "sql": "SELECT * FROM auth_user", - "raw_sql": "SELECT * FROM auth_user", - "params": "{}", - "alias": "default", - "duration": "0", + "request_id": request_id, + "djdt_query_id": djdt_query_id, } ) } response = self.client.post(url, data) self.assertEqual(response.status_code, 200) - response = self.client.post(url, data, HTTP_X_REQUESTED_WITH="XMLHttpRequest") + response = self.client.post( + url, data, headers={"x-requested-with": "XMLHttpRequest"} + ) self.assertEqual(response.status_code, 200) with self.settings(INTERNAL_IPS=[]): response = self.client.post(url, data) self.assertEqual(response.status_code, 404) response = self.client.post( - url, data, HTTP_X_REQUESTED_WITH="XMLHttpRequest" + url, data, headers={"x-requested-with": "XMLHttpRequest"} ) self.assertEqual(response.status_code, 404) @@ -378,57 +436,65 @@ def test_sql_explain_checks_show_toolbar(self): connection.vendor == "postgresql", "Test valid only on PostgreSQL" ) def test_sql_explain_postgres_json_field(self): + self.client.get("/execute_json_sql/") + request_ids = list(get_store().request_ids()) + request_id = request_ids[-1] + toolbar = DebugToolbar.fetch(request_id, SQLPanel.panel_id) + panel = toolbar.get_panel_by_id(SQLPanel.panel_id) + djdt_query_id = panel.get_stats()["queries"][-1]["djdt_query_id"] + url = "/__debug__/sql_explain/" - base_query = ( - 'SELECT * FROM "tests_postgresjson" WHERE "tests_postgresjson"."field" @>' - ) - query = base_query + """ '{"foo": "bar"}'""" data = { "signed": SignedDataForm.sign( { - "sql": query, - "raw_sql": base_query + " %s", - "params": '["{\\"foo\\": \\"bar\\"}"]', - "alias": "default", - "duration": "0", + "request_id": request_id, + "djdt_query_id": djdt_query_id, } ) } response = self.client.post(url, data) self.assertEqual(response.status_code, 200) - response = self.client.post(url, data, HTTP_X_REQUESTED_WITH="XMLHttpRequest") + response = self.client.post( + url, data, headers={"x-requested-with": "XMLHttpRequest"} + ) self.assertEqual(response.status_code, 200) with self.settings(INTERNAL_IPS=[]): response = self.client.post(url, data) self.assertEqual(response.status_code, 404) response = self.client.post( - url, data, HTTP_X_REQUESTED_WITH="XMLHttpRequest" + url, data, headers={"x-requested-with": "XMLHttpRequest"} ) self.assertEqual(response.status_code, 404) def test_sql_profile_checks_show_toolbar(self): + self.client.get("/execute_sql/") + request_ids = list(get_store().request_ids()) + request_id = request_ids[-1] + toolbar = DebugToolbar.fetch(request_id, SQLPanel.panel_id) + panel = toolbar.get_panel_by_id(SQLPanel.panel_id) + djdt_query_id = panel.get_stats()["queries"][-1]["djdt_query_id"] + url = "/__debug__/sql_profile/" data = { "signed": SignedDataForm.sign( { - "sql": "SELECT * FROM auth_user", - "raw_sql": "SELECT * FROM auth_user", - "params": "{}", - "alias": "default", - "duration": "0", + "request_id": request_id, + "djdt_query_id": djdt_query_id, } ) } response = self.client.post(url, data) self.assertEqual(response.status_code, 200) - response = self.client.post(url, data, HTTP_X_REQUESTED_WITH="XMLHttpRequest") + response = self.client.post( + url, data, headers={"x-requested-with": "XMLHttpRequest"} + ) self.assertEqual(response.status_code, 200) with self.settings(INTERNAL_IPS=[]): response = self.client.post(url, data) self.assertEqual(response.status_code, 404) response = self.client.post( - url, data, HTTP_X_REQUESTED_WITH="XMLHttpRequest" + url, data, headers={"x-requested-with": "XMLHttpRequest"} ) self.assertEqual(response.status_code, 404) @@ -442,7 +508,7 @@ def test_render_panels_in_request(self): response = self.client.get(url) self.assertIn(b'id="djDebug"', response.content) # Verify the store id is not included. - self.assertNotIn(b"data-store-id", response.content) + self.assertNotIn(b"data-request-id", response.content) # Verify the history panel was disabled self.assertIn( b'.x" + ) def test_lambda(self): res = get_name_from_obj(lambda: 1) - self.assertEqual(res, "tests.test_utils.") + self.assertEqual( + res, "tests.test_utils.GetNameFromObjTestCase.test_lambda.." + ) def test_class(self): class A: pass res = get_name_from_obj(A) - self.assertEqual(res, "tests.test_utils.A") + self.assertEqual( + res, "tests.test_utils.GetNameFromObjTestCase.test_class..A" + ) class RenderStacktraceTestCase(unittest.TestCase): diff --git a/tests/urls.py b/tests/urls.py index 6fc8811b7..c67ef28be 100644 --- a/tests/urls.py +++ b/tests/urls.py @@ -17,10 +17,12 @@ path("non_ascii_request/", views.regular_view, {"title": NonAsciiRepr()}), path("new_user/", views.new_user), path("execute_sql/", views.execute_sql), + path("execute_json_sql/", views.execute_json_sql), path("cached_view/", views.cached_view), path("cached_low_level_view/", views.cached_low_level_view), path("json_view/", views.json_view), path("redirect/", views.redirect_view), + path("ajax/", views.ajax_view), path("login_without_redirect/", LoginView.as_view(redirect_field_name=None)), path("admin/", admin.site.urls), path("__debug__/", include("debug_toolbar.urls")), diff --git a/tests/urls_invalid.py b/tests/urls_invalid.py index ccadb6735..a2a56699c 100644 --- a/tests/urls_invalid.py +++ b/tests/urls_invalid.py @@ -1,2 +1,3 @@ """Invalid urls.py file for testing""" + urlpatterns = [] diff --git a/tests/urls_use_package_urls.py b/tests/urls_use_package_urls.py index 50f7dfd69..0a3a91ab3 100644 --- a/tests/urls_use_package_urls.py +++ b/tests/urls_use_package_urls.py @@ -1,4 +1,5 @@ """urls.py to test using debug_toolbar.urls in include""" + from django.urls import include, path import debug_toolbar diff --git a/tests/views.py b/tests/views.py index b2fd21c54..5a508486d 100644 --- a/tests/views.py +++ b/tests/views.py @@ -5,12 +5,19 @@ from django.template.response import TemplateResponse from django.views.decorators.cache import cache_page +from tests.models import PostgresJSON + def execute_sql(request): list(User.objects.all()) return render(request, "base.html") +def execute_json_sql(request): + list(PostgresJSON.objects.filter(field__contains={"foo": "bar"})) + return render(request, "base.html") + + def regular_view(request, title): return render(request, "basic.html", {"title": title}) @@ -48,7 +55,7 @@ def json_view(request): def regular_jinjia_view(request, title): - return render(request, "jinja2/basic.jinja", {"title": title}) + return render(request, "basic.jinja", {"title": title}, using="jinja2") def listcomp_view(request): @@ -58,3 +65,7 @@ def listcomp_view(request): def redirect_view(request): return HttpResponseRedirect("/regular/redirect/") + + +def ajax_view(request): + return render(request, "ajax/ajax.html") diff --git a/tox.ini b/tox.ini index 5154d4907..a0e72827a 100644 --- a/tox.ini +++ b/tox.ini @@ -3,17 +3,13 @@ isolated_build = true envlist = docs packaging - py{38,39,310}-dj32-{sqlite,postgresql,postgis,mysql} - py310-dj40-sqlite - py{310,311}-dj41-{sqlite,postgresql,postgis,mysql} - py{310,311}-dj{42,main}-{sqlite,postgresql,psycopg3,postgis,mysql} + py{38,39,310,311,312}-dj{42}-{sqlite,postgresql,postgis,mysql} + py{310,311,312}-dj{42,50,main}-{sqlite,postgresql,psycopg3,postgis,mysql} [testenv] deps = - dj32: django~=3.2.9 - dj40: django~=4.0.0 - dj41: django~=4.1.3 dj42: django~=4.2.1 + dj50: django~=5.0.2 djmain: https://github.com/django/django/archive/main.tar.gz postgresql: psycopg2-binary psycopg3: psycopg[binary] @@ -34,12 +30,13 @@ passenv= DB_PASSWORD DB_HOST DB_PORT + DISPLAY + DJANGO_SELENIUM_TESTS GITHUB_* setenv = PYTHONPATH = {toxinidir} PYTHONWARNINGS = d - py39-dj32-postgresql: DJANGO_SELENIUM_TESTS = true - py311-dj42-postgresql: DJANGO_SELENIUM_TESTS = true + py311-dj42-postgresql: DJANGO_SELENIUM_TESTS = {env:DJANGO_SELENIUM_TESTS:true} DB_NAME = {env:DB_NAME:debug_toolbar} DB_USER = {env:DB_USER:debug_toolbar} DB_HOST = {env:DB_HOST:localhost} @@ -49,25 +46,29 @@ allowlist_externals = make pip_pre = True commands = python -b -W always -m coverage run -m django test -v2 {posargs:tests} -[testenv:py{38,39,310,311}-dj{32,40,41,42,main}-{postgresql,psycopg3}] + +[testenv:py{38,39,310,311,312}-dj{42,50,main}-{postgresql,psycopg3}] setenv = {[testenv]setenv} DB_BACKEND = postgresql DB_PORT = {env:DB_PORT:5432} -[testenv:py{38,39,310,311}-dj{32,40,41,42,main}-postgis] + +[testenv:py{38,39,310,311,312}-dj{42,50,main}-postgis] setenv = {[testenv]setenv} DB_BACKEND = postgis DB_PORT = {env:DB_PORT:5432} -[testenv:py{38,39,310,311}-dj{32,40,41,42,main}-mysql] + +[testenv:py{38,39,310,311,312}-dj{42,50,main}-mysql] setenv = {[testenv]setenv} DB_BACKEND = mysql DB_PORT = {env:DB_PORT:3306} -[testenv:py{38,39,310,311}-dj{32,40,41,42,main}-sqlite] + +[testenv:py{38,39,310,311,312}-dj{42,50,main}-sqlite] setenv = {[testenv]setenv} DB_BACKEND = sqlite3 @@ -95,6 +96,7 @@ python = 3.9: py39 3.10: py310 3.11: py311 + 3.12: py312 [gh-actions:env] DB_BACKEND =
    {{ key|pprint }} {{ value|pprint }} -

    {{ store_context.toolbar.stats.HistoryPanel.status_code|escape }}

    +

    {{ history_context.history_stats.status_code|escape }}

    - {{ store_context.form }} - + {{ history_context.form.as_div }} +