diff --git a/.flake8 b/.flake8 deleted file mode 100644 index 7d44b7eca..000000000 --- a/.flake8 +++ /dev/null @@ -1,4 +0,0 @@ -# TODO: move this to pyproject.toml when supported, see https://github.com/PyCQA/flake8/issues/234 - -[flake8] -extend-ignore = E203, E501 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index cc4b9a456..9dece68ef 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -20,9 +20,9 @@ jobs: mariadb: image: mariadb env: - MYSQL_ROOT_PASSWORD: debug_toolbar + MARIADB_ROOT_PASSWORD: debug_toolbar options: >- - --health-cmd "mysqladmin ping" + --health-cmd "mariadb-admin ping" --health-interval 10s --health-timeout 5s --health-retries 5 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 124892d78..001b07e34 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -7,63 +7,53 @@ repos: - id: end-of-file-fixer - id: trailing-whitespace - id: mixed-line-ending -- repo: https://github.com/pycqa/flake8 - rev: 6.0.0 - hooks: - - id: flake8 + - id: file-contents-sorter + files: docs/spelling_wordlist.txt - repo: https://github.com/pycqa/doc8 rev: v1.1.1 hooks: - id: doc8 -- repo: https://github.com/asottile/pyupgrade - rev: v3.4.0 - hooks: - - id: pyupgrade - args: [--py38-plus] - repo: https://github.com/adamchainz/django-upgrade - rev: 1.13.0 + rev: 1.14.0 hooks: - id: django-upgrade args: [--target-version, "3.2"] -- repo: https://github.com/pycqa/isort - rev: 5.12.0 - hooks: - - id: isort - repo: https://github.com/pre-commit/pygrep-hooks rev: v1.10.0 hooks: - - id: python-check-blanket-noqa - - id: python-check-mock-methods - - id: python-no-eval - - id: python-no-log-warn - id: rst-backticks - id: rst-directive-colons - repo: https://github.com/pre-commit/mirrors-prettier - rev: v3.0.0-alpha.9-for-vscode + rev: v3.0.1 hooks: - id: prettier types_or: [javascript, css] args: - --trailing-comma=es5 - repo: https://github.com/pre-commit/mirrors-eslint - rev: v8.40.0 + rev: v8.46.0 hooks: - id: eslint files: \.js?$ types: [file] args: - --fix +- repo: https://github.com/astral-sh/ruff-pre-commit + rev: 'v0.0.282' + hooks: + - id: ruff + args: [--fix, --exit-non-zero-on-fix] - repo: https://github.com/psf/black - rev: 23.3.0 + rev: 23.7.0 hooks: - id: black language_version: python3 entry: black --target-version=py38 - repo: https://github.com/tox-dev/pyproject-fmt - rev: 0.11.2 + rev: 0.13.0 hooks: - id: pyproject-fmt - repo: https://github.com/abravalheri/validate-pyproject - rev: v0.12.2 + rev: v0.13 hooks: - id: validate-pyproject diff --git a/debug_toolbar/__init__.py b/debug_toolbar/__init__.py index 1a9cf7c93..dbe08451f 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.1.0" +VERSION = "4.2.0" # Code that discovers files or modules in INSTALLED_APPS imports this module. urls = "debug_toolbar.urls", APP_NAME diff --git a/debug_toolbar/apps.py b/debug_toolbar/apps.py index c55b75392..0a10a4b08 100644 --- a/debug_toolbar/apps.py +++ b/debug_toolbar/apps.py @@ -1,4 +1,5 @@ import inspect +import mimetypes from django.apps import AppConfig from django.conf import settings @@ -32,8 +33,26 @@ def check_template_config(config): included in the loaders. If custom loaders are specified, then APP_DIRS must be True. """ + + def flat_loaders(loaders): + """ + Recursively flatten the settings list of template loaders. + + Check for (loader, [child_loaders]) tuples. + Django's default cached loader uses this pattern. + """ + for loader in loaders: + if isinstance(loader, tuple): + yield loader[0] + yield from flat_loaders(loader[1]) + else: + yield loader + app_dirs = config.get("APP_DIRS", False) loaders = config.get("OPTIONS", {}).get("loaders", None) + if loaders: + loaders = list(flat_loaders(loaders)) + # By default the app loader is included. has_app_loaders = ( loaders is None or "django.template.loaders.app_directories.Loader" in loaders @@ -156,3 +175,34 @@ def check_panels(app_configs, **kwargs): ) ) return errors + + +@register() +def js_mimetype_check(app_configs, **kwargs): + """ + Check that JavaScript files are resolving to the correct content type. + """ + # Ideally application/javascript is returned, but text/javascript is + # acceptable. + javascript_types = {"application/javascript", "text/javascript"} + check_failed = not set(mimetypes.guess_type("toolbar.js")).intersection( + javascript_types + ) + if check_failed: + return [ + Warning( + "JavaScript files are resolving to the wrong content type.", + hint="The Django Debug Toolbar may not load properly while mimetypes are misconfigured. " + "See the Django documentation for an explanation of why this occurs.\n" + "https://docs.djangoproject.com/en/stable/ref/contrib/staticfiles/#static-file-development-view\n" + "\n" + "This typically occurs on Windows machines. The suggested solution is to modify " + "HKEY_CLASSES_ROOT in the registry to specify the content type for JavaScript " + "files.\n" + "\n" + "[HKEY_CLASSES_ROOT\\.js]\n" + '"Content Type"="application/javascript"', + id="debug_toolbar.W007", + ) + ] + return [] diff --git a/debug_toolbar/forms.py b/debug_toolbar/forms.py index 1263c3aff..61444b43c 100644 --- a/debug_toolbar/forms.py +++ b/debug_toolbar/forms.py @@ -38,8 +38,8 @@ def clean_signed(self): signing.Signer(salt=self.salt).unsign(self.cleaned_data["signed"]) ) return verified - except signing.BadSignature: - raise ValidationError("Bad signature") + except signing.BadSignature as exc: + raise ValidationError("Bad signature") from exc def verified_data(self): return self.is_valid() and self.cleaned_data["signed"] diff --git a/debug_toolbar/panels/__init__.py b/debug_toolbar/panels/__init__.py index 6d9491b1f..57f385a5e 100644 --- a/debug_toolbar/panels/__init__.py +++ b/debug_toolbar/panels/__init__.py @@ -124,7 +124,6 @@ def ready(cls): be done unconditionally for the panel regardless of whether it is enabled for a particular request. It should be idempotent. """ - pass # URLs for panel-specific views diff --git a/debug_toolbar/panels/cache.py b/debug_toolbar/panels/cache.py index 31ce70988..4c7bf5af7 100644 --- a/debug_toolbar/panels/cache.py +++ b/debug_toolbar/panels/cache.py @@ -117,10 +117,7 @@ def _store_call_info( else: self.hits += 1 elif name == "get_many": - if "keys" in kwargs: - keys = kwargs["keys"] - else: - keys = args[0] + keys = kwargs["keys"] if "keys" in kwargs else args[0] self.hits += len(return_value) self.misses += len(keys) - len(return_value) time_taken *= 1000 diff --git a/debug_toolbar/panels/headers.py b/debug_toolbar/panels/headers.py index ed20d6178..e1ea6da1e 100644 --- a/debug_toolbar/panels/headers.py +++ b/debug_toolbar/panels/headers.py @@ -33,7 +33,7 @@ class HeadersPanel(Panel): template = "debug_toolbar/panels/headers.html" def process_request(self, request): - wsgi_env = list(sorted(request.META.items())) + wsgi_env = sorted(request.META.items()) self.request_headers = { unmangle(k): v for (k, v) in wsgi_env if is_http_header(k) } diff --git a/debug_toolbar/panels/history/panel.py b/debug_toolbar/panels/history/panel.py index 8bd0e8f65..508a60577 100644 --- a/debug_toolbar/panels/history/panel.py +++ b/debug_toolbar/panels/history/panel.py @@ -1,3 +1,4 @@ +import contextlib import json from django.http.request import RawPostDataException @@ -22,7 +23,7 @@ class HistoryPanel(Panel): def get_headers(self, request): headers = super().get_headers(request) observe_request = self.toolbar.get_observe_request() - store_id = getattr(self.toolbar, "store_id") + store_id = self.toolbar.store_id if store_id and observe_request(request): headers["djdt-store-id"] = store_id return headers @@ -62,10 +63,9 @@ def generate_stats(self, request, response): and request.body and request.headers.get("content-type") == "application/json" ): - try: + with contextlib.suppress(ValueError): data = json.loads(request.body) - except ValueError: - pass + except RawPostDataException: # It is not guaranteed that we may read the request data (again). data = None diff --git a/debug_toolbar/panels/profiling.py b/debug_toolbar/panels/profiling.py index ca32b98c2..9d10229ad 100644 --- a/debug_toolbar/panels/profiling.py +++ b/debug_toolbar/panels/profiling.py @@ -13,7 +13,7 @@ class FunctionCall: def __init__( - self, statobj, func, depth=0, stats=None, id=0, parent_ids=[], hsv=(0, 0.5, 1) + self, statobj, func, depth=0, stats=None, id=0, parent_ids=None, hsv=(0, 0.5, 1) ): self.statobj = statobj self.func = func @@ -23,7 +23,7 @@ def __init__( self.stats = statobj.stats[func][:4] self.depth = depth self.id = id - self.parent_ids = parent_ids + self.parent_ids = parent_ids or [] self.hsv = hsv def parent_classes(self): @@ -88,10 +88,7 @@ def subfuncs(self): for func, stats in self.statobj.all_callees[self.func].items(): i += 1 h1 = h + (i / count) / (self.depth + 1) - if stats[3] == 0: - s1 = 0 - else: - s1 = s * (stats[3] / self.stats[3]) + s1 = 0 if stats[3] == 0 else s * (stats[3] / self.stats[3]) yield FunctionCall( self.statobj, func, diff --git a/debug_toolbar/panels/redirects.py b/debug_toolbar/panels/redirects.py index f6a00b574..195d0cf11 100644 --- a/debug_toolbar/panels/redirects.py +++ b/debug_toolbar/panels/redirects.py @@ -18,9 +18,7 @@ def process_request(self, request): if 300 <= response.status_code < 400: redirect_to = response.get("Location") if redirect_to: - status_line = "{} {}".format( - response.status_code, response.reason_phrase - ) + status_line = f"{response.status_code} {response.reason_phrase}" cookies = response.cookies context = {"redirect_to": redirect_to, "status_line": status_line} # Using SimpleTemplateResponse avoids running global context processors. diff --git a/debug_toolbar/panels/request.py b/debug_toolbar/panels/request.py index bfb485ae7..a936eba6b 100644 --- a/debug_toolbar/panels/request.py +++ b/debug_toolbar/panels/request.py @@ -64,7 +64,5 @@ def generate_stats(self, request, response): (k, request.session.get(k)) for k in sorted(request.session.keys()) ] except TypeError: - session_list = [ - (k, request.session.get(k)) for k in request.session.keys() - ] + session_list = [(k, request.session.get(k)) for k in request.session] self.record_stats({"session": {"list": session_list}}) diff --git a/debug_toolbar/panels/sql/forms.py b/debug_toolbar/panels/sql/forms.py index 8d2709777..0515c5c8e 100644 --- a/debug_toolbar/panels/sql/forms.py +++ b/debug_toolbar/panels/sql/forms.py @@ -37,8 +37,8 @@ def clean_params(self): try: return json.loads(value) - except ValueError: - raise ValidationError("Is not valid JSON") + except ValueError as exc: + raise ValidationError("Is not valid JSON") from exc def clean_alias(self): value = self.cleaned_data["alias"] diff --git a/debug_toolbar/panels/sql/panel.py b/debug_toolbar/panels/sql/panel.py index c8576e16f..58c1c2738 100644 --- a/debug_toolbar/panels/sql/panel.py +++ b/debug_toolbar/panels/sql/panel.py @@ -6,6 +6,7 @@ from django.urls import path from django.utils.translation import gettext_lazy as _, ngettext +from debug_toolbar import settings as dt_settings from debug_toolbar.forms import SignedDataForm from debug_toolbar.panels import Panel from debug_toolbar.panels.sql import views @@ -89,7 +90,7 @@ def _duplicate_query_key(query): def _process_query_groups(query_groups, databases, colors, name): counts = defaultdict(int) - for (alias, key), query_group in query_groups.items(): + for (alias, _key), query_group in query_groups.items(): count = len(query_group) # Queries are similar / duplicates only if there are at least 2 of them. if count > 1: @@ -110,9 +111,7 @@ class SQLPanel(Panel): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self._offset = {k: len(connections[k].queries) for k in connections} self._sql_time = 0 - self._num_queries = 0 self._queries = [] self._databases = {} # synthetic transaction IDs, keyed by DB alias @@ -151,7 +150,6 @@ def record(self, **kwargs): self._databases[alias]["time_spent"] += kwargs["duration"] self._databases[alias]["num_queries"] += 1 self._sql_time += kwargs["duration"] - self._num_queries += 1 # Implement the Panel API @@ -159,12 +157,13 @@ def record(self, **kwargs): @property def nav_subtitle(self): + query_count = len(self._queries) return ngettext( "%(query_count)d query in %(sql_time).2fms", "%(query_count)d queries in %(sql_time).2fms", - self._num_queries, + query_count, ) % { - "query_count": self._num_queries, + "query_count": query_count, "sql_time": self._sql_time, } @@ -204,6 +203,8 @@ def generate_stats(self, request, response): duplicate_query_groups = defaultdict(list) if self._queries: + sql_warning_threshold = dt_settings.get_config()["SQL_WARNING_THRESHOLD"] + width_ratio_tally = 0 factor = int(256.0 / (len(self._databases) * 2.5)) for n, db in enumerate(self._databases.values()): @@ -261,6 +262,12 @@ def generate_stats(self, request, response): 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") + ) + query["rgb_color"] = self._databases[alias]["rgb_color"] try: query["width_ratio"] = (query["duration"] / self._sql_time) * 100 diff --git a/debug_toolbar/panels/sql/tracking.py b/debug_toolbar/panels/sql/tracking.py index a85ac51ad..0c53dc2c5 100644 --- a/debug_toolbar/panels/sql/tracking.py +++ b/debug_toolbar/panels/sql/tracking.py @@ -1,13 +1,12 @@ +import contextlib import contextvars import datetime import json from time import perf_counter import django.test.testcases -from django.db.backends.utils import CursorWrapper from django.utils.encoding import force_str -from debug_toolbar import settings as dt_settings from debug_toolbar.utils import get_stack_trace, get_template_info try: @@ -34,8 +33,14 @@ class SQLQueryTriggered(Exception): def wrap_cursor(connection): - # If running a Django SimpleTestCase, which isn't allowed to access the database, - # don't perform any monkey patching. + # When running a SimpleTestCase, Django monkey patches some DatabaseWrapper + # methods, including .cursor() and .chunked_cursor(), to raise an exception + # if the test code tries to access the database, and then undoes the monkey + # patching when the test case is finished. If we monkey patch those methods + # also, Django's process of undoing those monkey patches will fail. To + # avoid this failure, and because database access is not allowed during a + # SimpleTestCase anyway, skip applying our instrumentation monkey patches if + # we detect that Django has already monkey patched DatabaseWrapper.cursor(). if isinstance(connection.cursor, django.test.testcases._DatabaseFailure): return if not hasattr(connection, "_djdt_cursor"): @@ -54,37 +59,42 @@ def cursor(*args, **kwargs): cursor = connection._djdt_cursor(*args, **kwargs) if logger is None: return cursor - if allow_sql.get(): - wrapper = NormalCursorWrapper - else: - wrapper = ExceptionCursorWrapper - return wrapper(cursor.cursor, connection, logger) + mixin = NormalCursorMixin if allow_sql.get() else ExceptionCursorMixin + return patch_cursor_wrapper_with_mixin(cursor.__class__, mixin)( + cursor.cursor, connection, logger + ) def chunked_cursor(*args, **kwargs): # prevent double wrapping # solves https://github.com/jazzband/django-debug-toolbar/issues/1239 logger = connection._djdt_logger cursor = connection._djdt_chunked_cursor(*args, **kwargs) - if logger is not None and not isinstance(cursor, DjDTCursorWrapper): - if allow_sql.get(): - wrapper = NormalCursorWrapper - else: - wrapper = ExceptionCursorWrapper - return wrapper(cursor.cursor, connection, logger) + if logger is not None and not isinstance(cursor, DjDTCursorWrapperMixin): + mixin = NormalCursorMixin if allow_sql.get() else ExceptionCursorMixin + return patch_cursor_wrapper_with_mixin(cursor.__class__, mixin)( + cursor.cursor, connection, logger + ) return cursor connection.cursor = cursor connection.chunked_cursor = chunked_cursor -class DjDTCursorWrapper(CursorWrapper): +def patch_cursor_wrapper_with_mixin(base_wrapper, mixin): + class DjDTCursorWrapper(mixin, base_wrapper): + pass + + return DjDTCursorWrapper + + +class DjDTCursorWrapperMixin: def __init__(self, cursor, db, logger): super().__init__(cursor, db) # logger must implement a ``record`` method self.logger = logger -class ExceptionCursorWrapper(DjDTCursorWrapper): +class ExceptionCursorMixin(DjDTCursorWrapperMixin): """ Wraps a cursor and raises an exception on any operation. Used in Templates panel. @@ -94,7 +104,7 @@ def __getattr__(self, attr): raise SQLQueryTriggered() -class NormalCursorWrapper(DjDTCursorWrapper): +class NormalCursorMixin(DjDTCursorWrapperMixin): """ Wraps a cursor and logs queries. """ @@ -169,20 +179,22 @@ def _record(self, method, sql, params): stop_time = perf_counter() duration = (stop_time - start_time) * 1000 _params = "" - try: + with contextlib.suppress(TypeError): + # object JSON serializable? _params = json.dumps(self._decode(params)) - except TypeError: - pass # object not JSON serializable template_info = get_template_info() # Sql might be an object (such as psycopg Composed). # For logging purposes, make sure it's str. if vendor == "postgresql" and not isinstance(sql, str): - sql = sql.as_string(conn) + if isinstance(sql, bytes): + sql = sql.decode("utf-8") + else: + sql = sql.as_string(conn) else: sql = str(sql) - params = { + kwargs = { "vendor": vendor, "alias": alias, "sql": self._last_executed_query(sql, params), @@ -191,12 +203,6 @@ def _record(self, method, sql, params): "params": _params, "raw_params": params, "stacktrace": get_stack_trace(skip=2), - "start_time": start_time, - "stop_time": stop_time, - "is_slow": ( - duration > dt_settings.get_config()["SQL_WARNING_THRESHOLD"] - ), - "is_select": sql.lower().strip().startswith("select"), "template_info": template_info, } @@ -228,7 +234,7 @@ def _record(self, method, sql, params): else: trans_id = None - params.update( + kwargs.update( { "trans_id": trans_id, "trans_status": conn.info.transaction_status, @@ -237,7 +243,7 @@ def _record(self, method, sql, params): ) # We keep `sql` to maintain backwards compatibility - self.logger.record(**params) + self.logger.record(**kwargs) def callproc(self, procname, params=None): return self._record(super().callproc, procname, params) diff --git a/debug_toolbar/panels/sql/utils.py b/debug_toolbar/panels/sql/utils.py index efd7c1637..cb4eda348 100644 --- a/debug_toolbar/panels/sql/utils.py +++ b/debug_toolbar/panels/sql/utils.py @@ -86,7 +86,7 @@ def process(stmt): return "".join(escaped_value(token) for token in stmt.flatten()) -def reformat_sql(sql, with_toggle=False): +def reformat_sql(sql, *, with_toggle=False): formatted = parse_sql(sql) if not with_toggle: return formatted @@ -132,7 +132,7 @@ def contrasting_color_generator(): """ def rgb_to_hex(rgb): - return "#%02x%02x%02x" % tuple(rgb) + return "#{:02x}{:02x}{:02x}".format(*tuple(rgb)) triples = [ (1, 0, 0), diff --git a/debug_toolbar/panels/staticfiles.py b/debug_toolbar/panels/staticfiles.py index bee336249..5f9efb5c3 100644 --- a/debug_toolbar/panels/staticfiles.py +++ b/debug_toolbar/panels/staticfiles.py @@ -1,3 +1,5 @@ +import contextlib +from contextvars import ContextVar from os.path import join, normpath from django.conf import settings @@ -7,12 +9,6 @@ from django.utils.translation import gettext_lazy as _, ngettext from debug_toolbar import panels -from debug_toolbar.utils import ThreadCollector - -try: - import threading -except ImportError: - threading = None class StaticFile: @@ -33,15 +29,8 @@ def url(self): return storage.staticfiles_storage.url(self.path) -class FileCollector(ThreadCollector): - def collect(self, path, thread=None): - # handle the case of {% static "admin/" %} - if path.endswith("/"): - return - super().collect(StaticFile(path), thread) - - -collector = FileCollector() +# This will collect the StaticFile instances across threads. +used_static_files = ContextVar("djdt_static_used_static_files") class DebugConfiguredStorage(LazyObject): @@ -65,15 +54,16 @@ def _setup(self): configured_storage_cls = get_storage_class(settings.STATICFILES_STORAGE) class DebugStaticFilesStorage(configured_storage_cls): - def __init__(self, collector, *args, **kwargs): - super().__init__(*args, **kwargs) - self.collector = collector - def url(self, path): - self.collector.collect(path) + with contextlib.suppress(LookupError): + # For LookupError: + # The ContextVar wasn't set yet. Since the toolbar wasn't properly + # configured to handle this request, we don't need to capture + # the static file. + used_static_files.get().append(StaticFile(path)) return super().url(path) - self._wrapped = DebugStaticFilesStorage(collector) + self._wrapped = DebugStaticFilesStorage() _original_storage = storage.staticfiles_storage @@ -97,7 +87,7 @@ def title(self): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.num_found = 0 - self._paths = {} + self.used_paths = [] def enable_instrumentation(self): storage.staticfiles_storage = DebugConfiguredStorage() @@ -120,18 +110,22 @@ def nav_subtitle(self): ) % {"num_used": num_used} def process_request(self, request): - collector.clear_collection() - return super().process_request(request) + reset_token = used_static_files.set([]) + response = super().process_request(request) + # Make a copy of the used paths so that when the + # ContextVar is reset, our panel still has the data. + self.used_paths = used_static_files.get().copy() + # Reset the ContextVar to be empty again, removing the reference + # to the list of used files. + used_static_files.reset(reset_token) + return response def generate_stats(self, request, response): - used_paths = collector.get_collection() - self._paths[threading.current_thread()] = used_paths - self.record_stats( { "num_found": self.num_found, - "num_used": len(used_paths), - "staticfiles": used_paths, + "num_used": len(self.used_paths), + "staticfiles": self.used_paths, "staticfiles_apps": self.get_staticfiles_apps(), "staticfiles_dirs": self.get_staticfiles_dirs(), "staticfiles_finders": self.get_staticfiles_finders(), diff --git a/debug_toolbar/panels/templates/panel.py b/debug_toolbar/panels/templates/panel.py index 35d5b5191..72565f016 100644 --- a/debug_toolbar/panels/templates/panel.py +++ b/debug_toolbar/panels/templates/panel.py @@ -117,7 +117,7 @@ def _store_template_info(self, sender, **kwargs): value.model._meta.label, ) else: - token = allow_sql.set(False) + token = allow_sql.set(False) # noqa: FBT003 try: saferepr(value) # this MAY trigger a db query except SQLQueryTriggered: @@ -192,9 +192,7 @@ def generate_stats(self, request, response): template = self.templates[0]["template"] # django templates have the 'engine' attribute, while jinja # templates use 'backend' - engine_backend = getattr(template, "engine", None) or getattr( - template, "backend" - ) + engine_backend = getattr(template, "engine", None) or template.backend template_dirs = engine_backend.dirs else: context_processors = None diff --git a/debug_toolbar/settings.py b/debug_toolbar/settings.py index bf534a7da..eb6b59209 100644 --- a/debug_toolbar/settings.py +++ b/debug_toolbar/settings.py @@ -83,6 +83,7 @@ def get_panels(): warnings.warn( f"Please remove {logging_panel} from your DEBUG_TOOLBAR_PANELS setting.", DeprecationWarning, + stacklevel=1, ) return PANELS diff --git a/debug_toolbar/static/debug_toolbar/css/toolbar.css b/debug_toolbar/static/debug_toolbar/css/toolbar.css index 4adb0abb5..a35286a1f 100644 --- a/debug_toolbar/static/debug_toolbar/css/toolbar.css +++ b/debug_toolbar/static/debug_toolbar/css/toolbar.css @@ -1,10 +1,9 @@ /* Variable definitions */ :root { /* Font families are the same as in Django admin/css/base.css */ - --djdt-font-family-primary: -apple-system, BlinkMacSystemFont, "Segoe UI", - system-ui, Roboto, "Helvetica Neue", Arial, sans-serif, - "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", - "Noto Color Emoji"; + --djdt-font-family-primary: "Segoe UI", system-ui, Roboto, "Helvetica Neue", + Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", + "Segoe UI Symbol", "Noto Color Emoji"; --djdt-font-family-monospace: ui-monospace, Menlo, Monaco, "Cascadia Mono", "Segoe UI Mono", "Roboto Mono", "Oxygen Mono", "Ubuntu Monospace", "Source Code Pro", "Fira Mono", "Droid Sans Mono", "Courier New", diff --git a/debug_toolbar/static/debug_toolbar/js/toolbar.js b/debug_toolbar/static/debug_toolbar/js/toolbar.js index ef2e617f9..9546ef27e 100644 --- a/debug_toolbar/static/debug_toolbar/js/toolbar.js +++ b/debug_toolbar/static/debug_toolbar/js/toolbar.js @@ -341,9 +341,9 @@ const djdt = { options.path ? "; path=" + options.path : "", options.domain ? "; domain=" + options.domain : "", options.secure ? "; secure" : "", - "sameSite" in options - ? "; sameSite=" + options.samesite - : "; sameSite=Lax", + "samesite" in options + ? "; samesite=" + options.samesite + : "; samesite=lax", ].join(""); return value; diff --git a/debug_toolbar/toolbar.py b/debug_toolbar/toolbar.py index 31010f47f..11f8a1daa 100644 --- a/debug_toolbar/toolbar.py +++ b/debug_toolbar/toolbar.py @@ -86,7 +86,7 @@ def render_toolbar(self): "The debug toolbar requires the staticfiles contrib app. " "Add 'django.contrib.staticfiles' to INSTALLED_APPS and " "define STATIC_URL in your settings." - ) + ) from None else: raise diff --git a/debug_toolbar/utils.py b/debug_toolbar/utils.py index 4a7e9b2c3..7234f1f77 100644 --- a/debug_toolbar/utils.py +++ b/debug_toolbar/utils.py @@ -14,12 +14,6 @@ from debug_toolbar import _stubs as stubs, settings as dt_settings -try: - import threading -except ImportError: - threading = None - - _local_data = Local() @@ -168,10 +162,7 @@ def get_template_source_from_exception_info( def get_name_from_obj(obj: Any) -> str: - if hasattr(obj, "__name__"): - name = obj.__name__ - else: - name = obj.__class__.__name__ + name = obj.__name__ if hasattr(obj, "__name__") else obj.__class__.__name__ if hasattr(obj, "__module__"): module = obj.__module__ @@ -360,33 +351,3 @@ def get_stack_trace(*, skip=0): def clear_stack_trace_caches(): if hasattr(_local_data, "stack_trace_recorder"): del _local_data.stack_trace_recorder - - -class ThreadCollector: - def __init__(self): - if threading is None: - raise NotImplementedError( - "threading module is not available, " - "this panel cannot be used without it" - ) - self.collections = {} # a dictionary that maps threads to collections - - def get_collection(self, thread=None): - """ - Returns a list of collected items for the provided thread, of if none - is provided, returns a list for the current thread. - """ - if thread is None: - thread = threading.current_thread() - if thread not in self.collections: - self.collections[thread] = [] - return self.collections[thread] - - def clear_collection(self, thread=None): - if thread is None: - thread = threading.current_thread() - if thread in self.collections: - del self.collections[thread] - - def collect(self, item, thread=None): - self.get_collection(thread).append(item) diff --git a/docs/changes.rst b/docs/changes.rst index ad6607e34..89f5bdc7e 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -4,6 +4,23 @@ Change log Pending ------- +4.2.0 (2023-08-10) +------------------ + +* Adjusted app directories system check to allow for nested template loaders. +* Switched from flake8, isort and pyupgrade to `ruff + `__. +* Converted cookie keys to lowercase. Fixed the ``samesite`` argument to + ``djdt.cookie.set``. +* Converted ``StaticFilesPanel`` to no longer use a thread collector. Instead, + it collects the used static files in a ``ContextVar``. +* Added check ``debug_toolbar.W007`` to warn when JavaScript files are + resolving to the wrong content type. +* Fixed SQL statement recording under PostgreSQL for queries encoded as byte + strings. +* Patch the ``CursorWrapper`` class with a mixin class to support multiple + base wrapper classes. + 4.1.0 (2023-05-15) ------------------ @@ -150,7 +167,7 @@ Deprecated features 3.3.0 (2022-04-28) ------------------ -* Track calls to :py:meth:`django.core.caches.cache.get_or_set`. +* Track calls to :py:meth:`django.core.cache.cache.get_or_set`. * Removed support for Django < 3.2. * Updated check ``W006`` to look for ``django.template.loaders.app_directories.Loader``. @@ -172,7 +189,7 @@ Deprecated features * Changed cache monkey-patching for Django 3.2+ to iterate over existing caches and patch them individually rather than attempting to patch - ``django.core.caches`` as a whole. The ``middleware.cache`` is still + ``django.core.cache`` as a whole. The ``middleware.cache`` is still being patched as a whole in order to attempt to catch any cache usages before ``enable_instrumentation`` is called. * Add check ``W006`` to warn that the toolbar is incompatible with @@ -294,7 +311,7 @@ Deprecated features ``localStorage``. * Updated the code to avoid a few deprecation warnings and resource warnings. * Started loading JavaScript as ES6 modules. -* Added support for :meth:`cache.touch() ` when +* Added support for :meth:`cache.touch() ` when using django-debug-toolbar. * Eliminated more inline CSS. * Updated ``tox.ini`` and ``Makefile`` to use isort>=5. diff --git a/docs/checks.rst b/docs/checks.rst index b76f761a0..6ed1e88f4 100644 --- a/docs/checks.rst +++ b/docs/checks.rst @@ -18,3 +18,6 @@ Django Debug Toolbar setup and configuration: configuration needs to have ``django.template.loaders.app_directories.Loader`` included in ``["OPTIONS"]["loaders"]`` or ``APP_DIRS`` set to ``True``. +* **debug_toolbar.W007**: JavaScript files are resolving to the wrong content + type. Refer to :external:ref:`Django's explanation of + mimetypes on Windows `. diff --git a/docs/conf.py b/docs/conf.py index 2e4886c9c..7fa8e6fce 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.1.0" +release = "4.2.0" # -- General configuration --------------------------------------------------- @@ -51,7 +51,7 @@ # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. -html_theme = "default" +html_theme = "sphinx_rtd_theme" # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, diff --git a/docs/contributing.rst b/docs/contributing.rst index 079a8b195..5e11ee603 100644 --- a/docs/contributing.rst +++ b/docs/contributing.rst @@ -101,7 +101,7 @@ Style ----- The Django Debug Toolbar uses `black `__ to -format code and additionally uses flake8 and isort. The toolbar uses +format code and additionally uses ruff. The toolbar uses `pre-commit `__ to automatically apply our style guidelines when a commit is made. Set up pre-commit before committing with:: diff --git a/docs/panels.rst b/docs/panels.rst index 519571574..61a23ce61 100644 --- a/docs/panels.rst +++ b/docs/panels.rst @@ -421,7 +421,9 @@ common methods available. :param value: The value to be set. :param options: The options for the value to be set. It should contain the - properties ``expires`` and ``path``. + properties ``expires`` and ``path``. The properties ``domain``, + ``secure`` and ``samesite`` are also supported. ``samesite`` defaults + to ``lax`` if not provided. .. js:function:: djdt.hide_toolbar diff --git a/docs/spelling_wordlist.txt b/docs/spelling_wordlist.txt index d5aa73afe..7a15d9aeb 100644 --- a/docs/spelling_wordlist.txt +++ b/docs/spelling_wordlist.txt @@ -1,3 +1,12 @@ +Hatchling +Hotwire +Jazzband +Makefile +Pympler +Roboto +Transifex +Werkzeug +async backend backends backported @@ -9,22 +18,19 @@ fallbacks flamegraph flatpages frontend -Hatchling -Hotwire htmx inlining isort -Jazzband -jinja jQuery +jinja jrestclient js -Makefile margins memcache memcached middleware middlewares +mixin mousedown mouseup multi @@ -36,22 +42,18 @@ psycopg py pyflame pylibmc -Pympler +pyupgrade querysets refactoring resizing -Roboto spellchecking spooler stacktrace stacktraces startup -timeline theming +timeline tox -Transifex -unhashable uWSGI +unhashable validator -Werkzeug -async diff --git a/pyproject.toml b/pyproject.toml index 8729cb911..637dada5e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,21 +43,70 @@ dependencies = [ Download = "https://pypi.org/project/django-debug-toolbar/" Homepage = "https://github.com/jazzband/django-debug-toolbar" -[tool.hatch.build.targets.sdist] -include = [ - "/debug_toolbar", - "/CONTRIBUTING.md", -] - [tool.hatch.build.targets.wheel] packages = ["debug_toolbar"] [tool.hatch.version] path = "debug_toolbar/__init__.py" -[tool.isort] -combine_as_imports = true -profile = "black" +[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", +] [tool.coverage.html] skip_covered = true diff --git a/requirements_dev.txt b/requirements_dev.txt index ade334aba..8b24a8fbb 100644 --- a/requirements_dev.txt +++ b/requirements_dev.txt @@ -7,9 +7,7 @@ Jinja2 # Testing coverage[toml] -flake8 html5lib -isort selenium tox black @@ -18,6 +16,7 @@ black Sphinx sphinxcontrib-spelling +sphinx-rtd-theme>1 # Other tools diff --git a/tests/context_processors.py b/tests/context_processors.py index 6fe220dba..69e112a39 100644 --- a/tests/context_processors.py +++ b/tests/context_processors.py @@ -1,2 +1,2 @@ def broken(request): - request.non_existing_attribute + _read = request.non_existing_attribute diff --git a/tests/models.py b/tests/models.py index 95696020a..e19bfe59d 100644 --- a/tests/models.py +++ b/tests/models.py @@ -11,13 +11,22 @@ def __repr__(self): class Binary(models.Model): field = models.BinaryField() + def __str__(self): + return "" + class PostgresJSON(models.Model): field = JSONField() + def __str__(self): + return "" + if settings.USE_GIS: from django.contrib.gis.db import models as gismodels class Location(gismodels.Model): point = gismodels.PointField() + + def __str__(self): + return "" diff --git a/tests/panels/test_profiling.py b/tests/panels/test_profiling.py index 2169932b2..ff613dfe1 100644 --- a/tests/panels/test_profiling.py +++ b/tests/panels/test_profiling.py @@ -88,7 +88,6 @@ def test_view_executed_once(self): self.assertContains(response, "Profiling") self.assertEqual(User.objects.count(), 1) - with self.assertRaises(IntegrityError): - with transaction.atomic(): - response = self.client.get("/new_user/") + with self.assertRaises(IntegrityError), transaction.atomic(): + response = self.client.get("/new_user/") self.assertEqual(User.objects.count(), 1) diff --git a/tests/panels/test_sql.py b/tests/panels/test_sql.py index 7b3452935..932a0dd92 100644 --- a/tests/panels/test_sql.py +++ b/tests/panels/test_sql.py @@ -2,12 +2,13 @@ import datetime import os import unittest -from unittest.mock import patch +from unittest.mock import call, patch import django from asgiref.sync import sync_to_async from django.contrib.auth.models import User from django.db import connection, transaction +from django.db.backends.utils import CursorDebugWrapper, CursorWrapper from django.db.models import Count from django.db.utils import DatabaseError from django.shortcuts import render @@ -21,10 +22,10 @@ psycopg = None from ..base import BaseMultiDBTestCase, BaseTestCase -from ..models import PostgresJSON +from ..models import Binary, PostgresJSON -def sql_call(use_iterator=False): +def sql_call(*, use_iterator=False): qs = User.objects.all() if use_iterator: qs = qs.iterator() @@ -68,44 +69,64 @@ def test_recording_chunked_cursor(self): self.assertEqual(len(self.panel._queries), 1) @patch( - "debug_toolbar.panels.sql.tracking.NormalCursorWrapper", - wraps=sql_tracking.NormalCursorWrapper, + "debug_toolbar.panels.sql.tracking.patch_cursor_wrapper_with_mixin", + wraps=sql_tracking.patch_cursor_wrapper_with_mixin, ) - def test_cursor_wrapper_singleton(self, mock_wrapper): + def test_cursor_wrapper_singleton(self, mock_patch_cursor_wrapper): sql_call() - # ensure that cursor wrapping is applied only once - self.assertEqual(mock_wrapper.call_count, 1) + self.assertIn( + mock_patch_cursor_wrapper.mock_calls, + [ + [call(CursorWrapper, sql_tracking.NormalCursorMixin)], + # CursorDebugWrapper is used if the test is called with `--debug-sql` + [call(CursorDebugWrapper, sql_tracking.NormalCursorMixin)], + ], + ) @patch( - "debug_toolbar.panels.sql.tracking.NormalCursorWrapper", - wraps=sql_tracking.NormalCursorWrapper, + "debug_toolbar.panels.sql.tracking.patch_cursor_wrapper_with_mixin", + wraps=sql_tracking.patch_cursor_wrapper_with_mixin, ) - def test_chunked_cursor_wrapper_singleton(self, mock_wrapper): + def test_chunked_cursor_wrapper_singleton(self, mock_patch_cursor_wrapper): sql_call(use_iterator=True) # ensure that cursor wrapping is applied only once - self.assertEqual(mock_wrapper.call_count, 1) + self.assertIn( + mock_patch_cursor_wrapper.mock_calls, + [ + [call(CursorWrapper, sql_tracking.NormalCursorMixin)], + # CursorDebugWrapper is used if the test is called with `--debug-sql` + [call(CursorDebugWrapper, sql_tracking.NormalCursorMixin)], + ], + ) @patch( - "debug_toolbar.panels.sql.tracking.NormalCursorWrapper", - wraps=sql_tracking.NormalCursorWrapper, + "debug_toolbar.panels.sql.tracking.patch_cursor_wrapper_with_mixin", + wraps=sql_tracking.patch_cursor_wrapper_with_mixin, ) - async def test_cursor_wrapper_async(self, mock_wrapper): + async def test_cursor_wrapper_async(self, mock_patch_cursor_wrapper): await sync_to_async(sql_call)() - self.assertEqual(mock_wrapper.call_count, 1) + self.assertIn( + mock_patch_cursor_wrapper.mock_calls, + [ + [call(CursorWrapper, sql_tracking.NormalCursorMixin)], + # CursorDebugWrapper is used if the test is called with `--debug-sql` + [call(CursorDebugWrapper, sql_tracking.NormalCursorMixin)], + ], + ) @patch( - "debug_toolbar.panels.sql.tracking.NormalCursorWrapper", - wraps=sql_tracking.NormalCursorWrapper, + "debug_toolbar.panels.sql.tracking.patch_cursor_wrapper_with_mixin", + wraps=sql_tracking.patch_cursor_wrapper_with_mixin, ) - async def test_cursor_wrapper_asyncio_ctx(self, mock_wrapper): + async def test_cursor_wrapper_asyncio_ctx(self, mock_patch_cursor_wrapper): self.assertTrue(sql_tracking.allow_sql.get()) await sync_to_async(sql_call)() async def task(): - sql_tracking.allow_sql.set(False) + sql_tracking.allow_sql.set(False) # noqa: FBT003 # 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. @@ -116,7 +137,21 @@ async def task(): await asyncio.create_task(task()) # Because it was called in another context, it should not have affected ours self.assertTrue(sql_tracking.allow_sql.get()) - self.assertEqual(mock_wrapper.call_count, 1) + + self.assertIn( + mock_patch_cursor_wrapper.mock_calls, + [ + [ + call(CursorWrapper, sql_tracking.NormalCursorMixin), + call(CursorWrapper, sql_tracking.ExceptionCursorMixin), + ], + # CursorDebugWrapper is used if the test is called with `--debug-sql` + [ + call(CursorDebugWrapper, sql_tracking.NormalCursorMixin), + call(CursorDebugWrapper, sql_tracking.ExceptionCursorMixin), + ], + ], + ) def test_generate_server_timing(self): self.assertEqual(len(self.panel._queries), 0) @@ -149,7 +184,7 @@ def test_non_ascii_query(self): self.assertEqual(len(self.panel._queries), 2) # non-ASCII bytes parameters - list(User.objects.filter(username="café".encode())) + list(Binary.objects.filter(field__in=["café".encode()])) self.assertEqual(len(self.panel._queries), 3) response = self.panel.process_request(self.request) @@ -158,6 +193,17 @@ def test_non_ascii_query(self): # ensure the panel renders correctly self.assertIn("café", self.panel.content) + @unittest.skipUnless( + connection.vendor == "postgresql", "Test valid only on PostgreSQL" + ) + def test_bytes_query(self): + self.assertEqual(len(self.panel._queries), 0) + + with connection.cursor() as cursor: + cursor.execute(b"SELECT 1") + + self.assertEqual(len(self.panel._queries), 1) + def test_param_conversion(self): self.assertEqual(len(self.panel._queries), 0) @@ -335,7 +381,7 @@ def test_insert_content(self): Test that the panel only inserts content after generate_stats and not the process_request. """ - list(User.objects.filter(username="café".encode())) + 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) @@ -351,7 +397,7 @@ def test_insert_locals(self): Test that the panel inserts locals() content. """ local_var = "" # noqa: F841 - list(User.objects.filter(username="café".encode())) + list(User.objects.filter(username="café")) response = self.panel.process_request(self.request) self.panel.generate_stats(self.request, response) self.assertIn("local_var", self.panel.content) @@ -365,7 +411,7 @@ def test_not_insert_locals(self): """ Test that the panel does not insert locals() content. """ - list(User.objects.filter(username="café".encode())) + list(User.objects.filter(username="café")) response = self.panel.process_request(self.request) self.panel.generate_stats(self.request, response) self.assertNotIn("djdt-locals", self.panel.content) diff --git a/tests/test_checks.py b/tests/test_checks.py index 1e24688da..8e4f8e62f 100644 --- a/tests/test_checks.py +++ b/tests/test_checks.py @@ -1,5 +1,6 @@ import os import unittest +from unittest.mock import patch import django from django.conf import settings @@ -198,3 +199,62 @@ def test_check_w006_invalid(self): ) def test_check_w006_valid(self): self.assertEqual(run_checks(), []) + + @override_settings( + TEMPLATES=[ + { + "NAME": "use_loaders", + "BACKEND": "django.template.backends.django.DjangoTemplates", + "APP_DIRS": False, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.debug", + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", + ], + "loaders": [ + ( + "django.template.loaders.cached.Loader", + [ + "django.template.loaders.filesystem.Loader", + "django.template.loaders.app_directories.Loader", + ], + ), + ], + }, + }, + ] + ) + def test_check_w006_valid_nested_loaders(self): + self.assertEqual(run_checks(), []) + + @patch("debug_toolbar.apps.mimetypes.guess_type") + def test_check_w007_valid(self, mocked_guess_type): + mocked_guess_type.return_value = ("text/javascript", None) + self.assertEqual(run_checks(), []) + mocked_guess_type.return_value = ("application/javascript", None) + self.assertEqual(run_checks(), []) + + @patch("debug_toolbar.apps.mimetypes.guess_type") + def test_check_w007_invalid(self, mocked_guess_type): + mocked_guess_type.return_value = ("text/plain", None) + self.assertEqual( + run_checks(), + [ + Warning( + "JavaScript files are resolving to the wrong content type.", + hint="The Django Debug Toolbar may not load properly while mimetypes are misconfigured. " + "See the Django documentation for an explanation of why this occurs.\n" + "https://docs.djangoproject.com/en/stable/ref/contrib/staticfiles/#static-file-development-view\n" + "\n" + "This typically occurs on Windows machines. The suggested solution is to modify " + "HKEY_CLASSES_ROOT in the registry to specify the content type for JavaScript " + "files.\n" + "\n" + "[HKEY_CLASSES_ROOT\\.js]\n" + '"Content Type"="application/javascript"', + id="debug_toolbar.W007", + ) + ], + ) diff --git a/tests/test_integration.py b/tests/test_integration.py index 71340709a..b77b7cede 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -533,7 +533,8 @@ class DebugToolbarLiveTestCase(StaticLiveServerTestCase): def setUpClass(cls): super().setUpClass() options = Options() - options.headless = bool(os.environ.get("CI")) + if os.environ.get("CI"): + options.add_argument("-headless") cls.selenium = webdriver.Firefox(options=options) @classmethod diff --git a/tox.ini b/tox.ini index d751d5325..5154d4907 100644 --- a/tox.ini +++ b/tox.ini @@ -3,28 +3,27 @@ isolated_build = true envlist = docs packaging - py{38,39,310}-dj{32}-{sqlite,postgresql,postgis,mysql} - py{310}-dj{40}-{sqlite} - py{310,311}-dj{41}-{sqlite,postgresql,postgis,mysql} - py{310,311}-dj{42,main}-{sqlite,postgresql,postgis,mysql} - py{310,311}-dj{42,main}-psycopg3 + 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} [testenv] deps = dj32: django~=3.2.9 dj40: django~=4.0.0 dj41: django~=4.1.3 - dj42: django>=4.2a1,<5 + dj42: django~=4.2.1 + djmain: https://github.com/django/django/archive/main.tar.gz postgresql: psycopg2-binary psycopg3: psycopg[binary] postgis: psycopg2-binary mysql: mysqlclient - djmain: https://github.com/django/django/archive/main.tar.gz coverage[toml] Jinja2 html5lib pygments - selenium + selenium>=4.8.0 sqlparse passenv= CI @@ -40,7 +39,7 @@ setenv = PYTHONPATH = {toxinidir} PYTHONWARNINGS = d py39-dj32-postgresql: DJANGO_SELENIUM_TESTS = true - py310-dj41-postgresql: DJANGO_SELENIUM_TESTS = true + py311-dj42-postgresql: DJANGO_SELENIUM_TESTS = true DB_NAME = {env:DB_NAME:debug_toolbar} DB_USER = {env:DB_USER:debug_toolbar} DB_HOST = {env:DB_HOST:localhost} @@ -56,7 +55,6 @@ setenv = DB_BACKEND = postgresql DB_PORT = {env:DB_PORT:5432} - [testenv:py{38,39,310,311}-dj{32,40,41,42,main}-postgis] setenv = {[testenv]setenv} @@ -80,6 +78,7 @@ commands = make -C {toxinidir}/docs {posargs:spelling} deps = Sphinx sphinxcontrib-spelling + sphinx-rtd-theme [testenv:packaging] commands =