diff --git a/django/urls/resolvers.py b/django/urls/resolvers.py index 4f2aefc9b0..8f59313c93 100644 --- a/django/urls/resolvers.py +++ b/django/urls/resolvers.py @@ -28,11 +28,12 @@ from .utils import get_callable class ResolverMatch: - def __init__(self, func, args, kwargs, url_name=None, app_names=None, namespaces=None): + def __init__(self, func, args, kwargs, url_name=None, app_names=None, namespaces=None, route=None): self.func = func self.args = args self.kwargs = kwargs self.url_name = url_name + self.route = route # If a URLRegexResolver doesn't have a namespace or app_name, it passes # in an empty value. @@ -55,9 +56,9 @@ class ResolverMatch: return (self.func, self.args, self.kwargs)[index] def __repr__(self): - return "ResolverMatch(func=%s, args=%s, kwargs=%s, url_name=%s, app_names=%s, namespaces=%s)" % ( + return "ResolverMatch(func=%s, args=%s, kwargs=%s, url_name=%s, app_names=%s, namespaces=%s, route=%s)" % ( self._func_path, self.args, self.kwargs, self.url_name, - self.app_names, self.namespaces, + self.app_names, self.namespaces, self.route, ) @@ -345,7 +346,7 @@ class URLPattern: new_path, args, kwargs = match # Pass any extra_kwargs as **kwargs. kwargs.update(self.default_args) - return ResolverMatch(self.callback, args, kwargs, self.pattern.name) + return ResolverMatch(self.callback, args, kwargs, self.pattern.name, route=str(self.pattern)) @cached_property def lookup_str(self): @@ -503,6 +504,15 @@ class URLResolver: self._populate() return self._app_dict[language_code] + @staticmethod + def _join_route(route1, route2): + """Join two routes, without the starting ^ in the second route.""" + if not route1: + return route2 + if route2.startswith('^'): + route2 = route2[1:] + return route1 + route2 + def _is_callback(self, name): if not self._populated: self._populate() @@ -534,6 +544,7 @@ class URLResolver: sub_match_args = sub_match.args if not sub_match_dict: sub_match_args = args + sub_match.args + current_route = '' if isinstance(pattern, URLPattern) else str(pattern.pattern) return ResolverMatch( sub_match.func, sub_match_args, @@ -541,6 +552,7 @@ class URLResolver: sub_match.url_name, [self.app_name] + sub_match.app_names, [self.namespace] + sub_match.namespaces, + self._join_route(current_route, sub_match.route), ) tried.append([pattern]) raise Resolver404({'tried': tried, 'path': new_path}) diff --git a/docs/ref/urlresolvers.txt b/docs/ref/urlresolvers.txt index 0afdc4430f..5da5426923 100644 --- a/docs/ref/urlresolvers.txt +++ b/docs/ref/urlresolvers.txt @@ -130,6 +130,15 @@ If the URL does not resolve, the function raises a The name of the URL pattern that matches the URL. + .. attribute:: ResolverMatch.route + + .. versionadded:: 2.2 + + The route of the matching URL pattern. + + For example, if ``path('users//', ...)`` is the matching pattern, + ``route`` will contain ``'users//'``. + .. attribute:: ResolverMatch.app_name The application namespace for the URL pattern that matches the diff --git a/docs/releases/2.2.txt b/docs/releases/2.2.txt index 93a4a68994..f785d109ce 100644 --- a/docs/releases/2.2.txt +++ b/docs/releases/2.2.txt @@ -268,7 +268,8 @@ Tests URLs ~~~~ -* ... +* The new :attr:`.ResolverMatch.route` attribute stores the route of the + matching URL pattern. Validators ~~~~~~~~~~ diff --git a/tests/urlpatterns/included_urls.py b/tests/urlpatterns/included_urls.py index d45e2764c5..76e4551f57 100644 --- a/tests/urlpatterns/included_urls.py +++ b/tests/urlpatterns/included_urls.py @@ -1,7 +1,8 @@ -from django.urls import path +from django.urls import include, path from . import views urlpatterns = [ path('extra//', views.empty_view, name='inner-extra'), + path('', include('urlpatterns.more_urls')), ] diff --git a/tests/urlpatterns/more_urls.py b/tests/urlpatterns/more_urls.py new file mode 100644 index 0000000000..c7d789dda0 --- /dev/null +++ b/tests/urlpatterns/more_urls.py @@ -0,0 +1,7 @@ +from django.urls import re_path + +from . import views + +urlpatterns = [ + re_path(r'^more/(?P\w+)/$', views.empty_view, name='inner-more'), +] diff --git a/tests/urlpatterns/path_urls.py b/tests/urlpatterns/path_urls.py index 857ec0cd0b..4c80c840d9 100644 --- a/tests/urlpatterns/path_urls.py +++ b/tests/urlpatterns/path_urls.py @@ -1,5 +1,5 @@ from django.conf.urls import include -from django.urls import path +from django.urls import path, re_path from . import views @@ -11,5 +11,7 @@ urlpatterns = [ path('users/', views.empty_view, name='users'), path('users//', views.empty_view, name='user-with-id'), path('included_urls/', include('urlpatterns.included_urls')), + re_path(r'^regex/(?P[0-9]+)/$', views.empty_view, name='regex'), + path('', include('urlpatterns.more_urls')), path('//', views.empty_view, name='lang-and-path'), ] diff --git a/tests/urlpatterns/tests.py b/tests/urlpatterns/tests.py index 299258e56f..845d76c7af 100644 --- a/tests/urlpatterns/tests.py +++ b/tests/urlpatterns/tests.py @@ -26,23 +26,48 @@ class SimplifiedURLTests(SimpleTestCase): self.assertEqual(match.url_name, 'articles-2003') self.assertEqual(match.args, ()) self.assertEqual(match.kwargs, {}) + self.assertEqual(match.route, 'articles/2003/') def test_path_lookup_with_typed_parameters(self): match = resolve('/articles/2015/') self.assertEqual(match.url_name, 'articles-year') self.assertEqual(match.args, ()) self.assertEqual(match.kwargs, {'year': 2015}) + self.assertEqual(match.route, 'articles//') def test_path_lookup_with_multiple_paramaters(self): match = resolve('/articles/2015/04/12/') self.assertEqual(match.url_name, 'articles-year-month-day') self.assertEqual(match.args, ()) self.assertEqual(match.kwargs, {'year': 2015, 'month': 4, 'day': 12}) + self.assertEqual(match.route, 'articles////') def test_two_variable_at_start_of_path_pattern(self): match = resolve('/en/foo/') self.assertEqual(match.url_name, 'lang-and-path') self.assertEqual(match.kwargs, {'lang': 'en', 'url': 'foo'}) + self.assertEqual(match.route, '//') + + def test_re_path(self): + match = resolve('/regex/1/') + self.assertEqual(match.url_name, 'regex') + self.assertEqual(match.kwargs, {'pk': '1'}) + self.assertEqual(match.route, '^regex/(?P[0-9]+)/$') + + def test_path_lookup_with_inclusion(self): + match = resolve('/included_urls/extra/something/') + self.assertEqual(match.url_name, 'inner-extra') + self.assertEqual(match.route, 'included_urls/extra//') + + def test_path_lookup_with_empty_string_inclusion(self): + match = resolve('/more/99/') + self.assertEqual(match.url_name, 'inner-more') + self.assertEqual(match.route, r'^more/(?P\w+)/$') + + def test_path_lookup_with_double_inclusion(self): + match = resolve('/included_urls/more/some_value/') + self.assertEqual(match.url_name, 'inner-more') + self.assertEqual(match.route, r'included_urls/more/(?P\w+)/$') def test_path_reverse_without_parameter(self): url = reverse('articles-2003') diff --git a/tests/urlpatterns_reverse/tests.py b/tests/urlpatterns_reverse/tests.py index 8db38482ba..ff3ec045fb 100644 --- a/tests/urlpatterns_reverse/tests.py +++ b/tests/urlpatterns_reverse/tests.py @@ -1130,7 +1130,7 @@ class ResolverMatchTests(SimpleTestCase): repr(resolve('/no_kwargs/42/37/')), "ResolverMatch(func=urlpatterns_reverse.views.empty_view, " "args=('42', '37'), kwargs={}, url_name=no-kwargs, app_names=[], " - "namespaces=[])" + "namespaces=[], route=^no_kwargs/([0-9]+)/([0-9]+)/$)", )