diff --git a/django/urls/resolvers.py b/django/urls/resolvers.py index 5e3e6dea16..2a9dc11f28 100644 --- a/django/urls/resolvers.py +++ b/django/urls/resolvers.py @@ -30,12 +30,13 @@ from .utils import get_callable class ResolverMatch: - def __init__(self, func, args, kwargs, url_name=None, app_names=None, namespaces=None, route=None): + def __init__(self, func, args, kwargs, url_name=None, app_names=None, namespaces=None, route=None, tried=None): self.func = func self.args = args self.kwargs = kwargs self.url_name = url_name self.route = route + self.tried = tried # If a URLRegexResolver doesn't have a namespace or app_name, it passes # in an empty value. @@ -525,6 +526,13 @@ class URLResolver: self._populate() return self._app_dict[language_code] + @staticmethod + def _extend_tried(tried, pattern, sub_tried=None): + if sub_tried is None: + tried.append([pattern]) + else: + tried.extend([pattern, *t] for t in sub_tried) + @staticmethod def _join_route(route1, route2): """Join two routes, without the starting ^ in the second route.""" @@ -549,11 +557,7 @@ class URLResolver: try: sub_match = pattern.resolve(new_path) except Resolver404 as e: - sub_tried = e.args[0].get('tried') - if sub_tried is not None: - tried.extend([pattern] + t for t in sub_tried) - else: - tried.append([pattern]) + self._extend_tried(tried, pattern, e.args[0].get('tried')) else: if sub_match: # Merge captured arguments in match with submatch @@ -566,6 +570,7 @@ class URLResolver: if not sub_match_dict: sub_match_args = args + sub_match.args current_route = '' if isinstance(pattern, URLPattern) else str(pattern.pattern) + self._extend_tried(tried, pattern, sub_match.tried) return ResolverMatch( sub_match.func, sub_match_args, @@ -574,8 +579,9 @@ class URLResolver: [self.app_name] + sub_match.app_names, [self.namespace] + sub_match.namespaces, self._join_route(current_route, sub_match.route), + tried, ) - tried.append([pattern]) + self._extend_tried(tried, pattern) raise Resolver404({'tried': tried, 'path': new_path}) raise Resolver404({'path': path}) diff --git a/django/views/debug.py b/django/views/debug.py index e45ef01ace..7a89f7bd15 100644 --- a/django/views/debug.py +++ b/django/views/debug.py @@ -481,8 +481,10 @@ def technical_404_response(request, exception): try: tried = exception.args[0]['tried'] except (IndexError, TypeError, KeyError): - tried = [] + resolved = True + tried = request.resolver_match.tried if request.resolver_match else None else: + resolved = False if (not tried or ( # empty URLconf request.path == '/' and len(tried) == 1 and # default URLconf @@ -520,6 +522,7 @@ def technical_404_response(request, exception): 'root_urlconf': settings.ROOT_URLCONF, 'request_path': error_url, 'urlpatterns': tried, + 'resolved': resolved, 'reason': str(exception), 'request': request, 'settings': reporter_filter.get_safe_settings(), diff --git a/django/views/templates/technical_404.html b/django/views/templates/technical_404.html index 694309aa13..077bb20964 100644 --- a/django/views/templates/technical_404.html +++ b/django/views/templates/technical_404.html @@ -60,8 +60,11 @@

{% if request_path %} - The current path, {{ request_path }},{% else %} - The empty path{% endif %} didn’t match any of these. + The current path, {{ request_path }}, + {% else %} + The empty path + {% endif %} + {% if resolved %}matched the last one.{% else %}didn’t match any of these.{% endif %}

{% else %}

{{ reason }}

diff --git a/docs/ref/urlresolvers.txt b/docs/ref/urlresolvers.txt index 745ee9d6fd..b9af97f790 100644 --- a/docs/ref/urlresolvers.txt +++ b/docs/ref/urlresolvers.txt @@ -137,6 +137,13 @@ If the URL does not resolve, the function raises a For example, if ``path('users//', ...)`` is the matching pattern, ``route`` will contain ``'users//'``. + .. attribute:: ResolverMatch.tried + + .. versionadded:: 3.2 + + The list of URL patterns tried before the URL either matched one or + exhausted available patterns. + .. attribute:: ResolverMatch.app_name The application namespace for the URL pattern that matches the diff --git a/tests/view_tests/tests/test_debug.py b/tests/view_tests/tests/test_debug.py index 6e839b44f5..6d1b3e6f48 100644 --- a/tests/view_tests/tests/test_debug.py +++ b/tests/view_tests/tests/test_debug.py @@ -113,13 +113,25 @@ class DebugViewTests(SimpleTestCase): def test_404(self): response = self.client.get('/raises404/') self.assertEqual(response.status_code, 404) - self.assertContains(response, 'not-in-urls, didn’t match', status_code=404) + self.assertContains( + response, + '

The current path, not-in-urls, didn’t match any ' + 'of these.

', + status_code=404, + html=True, + ) def test_404_not_in_urls(self): response = self.client.get('/not-in-urls') self.assertNotContains(response, "Raised by:", status_code=404) self.assertContains(response, "Django tried these URL patterns", status_code=404) - self.assertContains(response, 'not-in-urls, didn’t match', status_code=404) + self.assertContains( + response, + '

The current path, not-in-urls, didn’t match any ' + 'of these.

', + status_code=404, + html=True, + ) # Pattern and view name of a RegexURLPattern appear. self.assertContains(response, r"^regex-post/(?P<pk>[0-9]+)/$", status_code=404) self.assertContains(response, "[name='regex-post']", status_code=404) @@ -130,12 +142,24 @@ class DebugViewTests(SimpleTestCase): @override_settings(ROOT_URLCONF=WithoutEmptyPathUrls) def test_404_empty_path_not_in_urls(self): response = self.client.get('/') - self.assertContains(response, 'The empty path didn’t match any of these.', status_code=404) + self.assertContains( + response, + '

The empty path didn’t match any of these.

', + status_code=404, + html=True, + ) def test_technical_404(self): response = self.client.get('/technical404/') self.assertContains(response, "Raised by:", status_code=404) self.assertContains(response, "view_tests.views.technical404", status_code=404) + self.assertContains( + response, + '

The current path, technical404/, matched the ' + 'last one.

', + status_code=404, + html=True, + ) def test_classbased_technical_404(self): response = self.client.get('/classbased404/')