summaryrefslogtreecommitdiff
path: root/django
diff options
context:
space:
mode:
authorSjoerd Job Postmus <sjoerdjob@sjec.nl>2016-10-20 19:29:04 +0200
committerTim Graham <timograham@gmail.com>2017-09-20 18:04:42 -0400
commitdf41b5a05d4e00e80e73afe629072e37873e767a (patch)
treebaaf71ae695e2d3af604ea0d663284cb406c71e4 /django
parentc4c128d67c7dc2830631c6859a204c9d259f1fb1 (diff)
Fixed #28593 -- Added a simplified URL routing syntax per DEP 0201.
Thanks Aymeric Augustin for shepherding the DEP and patch review. Thanks Marten Kenbeek and Tim Graham for contributing to the code. Thanks Tom Christie, Shai Berger, and Tim Graham for the docs.
Diffstat (limited to 'django')
-rw-r--r--django/conf/project_template/project_name/urls.py-tpl12
-rw-r--r--django/conf/urls/__init__.py11
-rw-r--r--django/conf/urls/i18n.py16
-rw-r--r--django/conf/urls/static.py4
-rw-r--r--django/contrib/admin/options.py19
-rw-r--r--django/contrib/admin/sites.py34
-rw-r--r--django/contrib/admindocs/urls.py56
-rw-r--r--django/contrib/admindocs/views.py5
-rw-r--r--django/contrib/auth/admin.py7
-rw-r--r--django/contrib/auth/urls.py19
-rw-r--r--django/contrib/flatpages/urls.py4
-rw-r--r--django/core/checks/urls.py4
-rw-r--r--django/template/defaulttags.py8
-rw-r--r--django/urls/__init__.py20
-rw-r--r--django/urls/conf.py31
-rw-r--r--django/urls/converters.py70
-rw-r--r--django/urls/resolvers.py415
-rw-r--r--django/views/generic/dates.py2
-rw-r--r--django/views/templates/technical_404.html2
19 files changed, 482 insertions, 257 deletions
diff --git a/django/conf/project_template/project_name/urls.py-tpl b/django/conf/project_template/project_name/urls.py-tpl
index 30ddffb876..e23d6a92ba 100644
--- a/django/conf/project_template/project_name/urls.py-tpl
+++ b/django/conf/project_template/project_name/urls.py-tpl
@@ -5,17 +5,17 @@ The `urlpatterns` list routes URLs to views. For more information please see:
Examples:
Function views
1. Add an import: from my_app import views
- 2. Add a URL to urlpatterns: url(r'^$', views.home, name='home')
+ 2. Add a URL to urlpatterns: path('', views.home, name='home')
Class-based views
1. Add an import: from other_app.views import Home
- 2. Add a URL to urlpatterns: url(r'^$', Home.as_view(), name='home')
+ 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home')
Including another URLconf
- 1. Import the include() function: from django.conf.urls import url, include
- 2. Add a URL to urlpatterns: url(r'^blog/', include('blog.urls'))
+ 1. Import the include() function: from django.urls import include, path
+ 2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
"""
-from django.conf.urls import url
from django.contrib import admin
+from django.urls import path
urlpatterns = [
- url(r'^admin/', admin.site.urls),
+ path('admin/', admin.site.urls),
]
diff --git a/django/conf/urls/__init__.py b/django/conf/urls/__init__.py
index b7ad156122..7bda34516b 100644
--- a/django/conf/urls/__init__.py
+++ b/django/conf/urls/__init__.py
@@ -1,4 +1,4 @@
-from django.urls import RegexURLPattern, RegexURLResolver, include
+from django.urls import include, re_path
from django.views import defaults
__all__ = ['handler400', 'handler403', 'handler404', 'handler500', 'include', 'url']
@@ -10,11 +10,4 @@ handler500 = defaults.server_error
def url(regex, view, kwargs=None, name=None):
- if isinstance(view, (list, tuple)):
- # For include(...) processing.
- urlconf_module, app_name, namespace = view
- return RegexURLResolver(regex, urlconf_module, kwargs, app_name=app_name, namespace=namespace)
- elif callable(view):
- return RegexURLPattern(regex, view, kwargs, name)
- else:
- raise TypeError('view must be a callable or a list/tuple in the case of include().')
+ return re_path(regex, view, kwargs, name)
diff --git a/django/conf/urls/i18n.py b/django/conf/urls/i18n.py
index 70d99b4f29..325cc9e60e 100644
--- a/django/conf/urls/i18n.py
+++ b/django/conf/urls/i18n.py
@@ -1,8 +1,7 @@
import functools
from django.conf import settings
-from django.conf.urls import url
-from django.urls import LocaleRegexURLResolver, get_resolver
+from django.urls import LocalePrefixPattern, URLResolver, get_resolver, path
from django.views.i18n import set_language
@@ -13,7 +12,12 @@ def i18n_patterns(*urls, prefix_default_language=True):
"""
if not settings.USE_I18N:
return list(urls)
- return [LocaleRegexURLResolver(list(urls), prefix_default_language=prefix_default_language)]
+ return [
+ URLResolver(
+ LocalePrefixPattern(prefix_default_language=prefix_default_language),
+ list(urls),
+ )
+ ]
@functools.lru_cache(maxsize=None)
@@ -25,11 +29,11 @@ def is_language_prefix_patterns_used(urlconf):
)
"""
for url_pattern in get_resolver(urlconf).url_patterns:
- if isinstance(url_pattern, LocaleRegexURLResolver):
- return True, url_pattern.prefix_default_language
+ if isinstance(url_pattern.pattern, LocalePrefixPattern):
+ return True, url_pattern.pattern.prefix_default_language
return False, False
urlpatterns = [
- url(r'^setlang/$', set_language, name='set_language'),
+ path('setlang/', set_language, name='set_language'),
]
diff --git a/django/conf/urls/static.py b/django/conf/urls/static.py
index 216602229f..150f4ffd3f 100644
--- a/django/conf/urls/static.py
+++ b/django/conf/urls/static.py
@@ -1,8 +1,8 @@
import re
from django.conf import settings
-from django.conf.urls import url
from django.core.exceptions import ImproperlyConfigured
+from django.urls import re_path
from django.views.static import serve
@@ -23,5 +23,5 @@ def static(prefix, view=serve, **kwargs):
# No-op if not in debug mode or a non-local prefix.
return []
return [
- url(r'^%s(?P<path>.*)$' % re.escape(prefix.lstrip('/')), view, kwargs=kwargs),
+ re_path(r'^%s(?P<path>.*)$' % re.escape(prefix.lstrip('/')), view, kwargs=kwargs),
]
diff --git a/django/contrib/admin/options.py b/django/contrib/admin/options.py
index 7a4ff947a8..6e4ad180ac 100644
--- a/django/contrib/admin/options.py
+++ b/django/contrib/admin/options.py
@@ -567,7 +567,7 @@ class ModelAdmin(BaseModelAdmin):
return inline_instances
def get_urls(self):
- from django.conf.urls import url
+ from django.urls import path
def wrap(view):
def wrapper(*args, **kwargs):
@@ -578,14 +578,14 @@ class ModelAdmin(BaseModelAdmin):
info = self.model._meta.app_label, self.model._meta.model_name
urlpatterns = [
- url(r'^$', wrap(self.changelist_view), name='%s_%s_changelist' % info),
- url(r'^add/$', wrap(self.add_view), name='%s_%s_add' % info),
- url(r'^autocomplete/$', wrap(self.autocomplete_view), name='%s_%s_autocomplete' % info),
- url(r'^(.+)/history/$', wrap(self.history_view), name='%s_%s_history' % info),
- url(r'^(.+)/delete/$', wrap(self.delete_view), name='%s_%s_delete' % info),
- url(r'^(.+)/change/$', wrap(self.change_view), name='%s_%s_change' % info),
+ path('', wrap(self.changelist_view), name='%s_%s_changelist' % info),
+ path('add/', wrap(self.add_view), name='%s_%s_add' % info),
+ path('autocomplete/', wrap(self.autocomplete_view), name='%s_%s_autocomplete' % info),
+ path('<path:object_id>/history/', wrap(self.history_view), name='%s_%s_history' % info),
+ path('<path:object_id>/delete/', wrap(self.delete_view), name='%s_%s_delete' % info),
+ path('<path:object_id>/change/', wrap(self.change_view), name='%s_%s_change' % info),
# For backwards compatibility (was the change url before 1.9)
- url(r'^(.+)/$', wrap(RedirectView.as_view(
+ path('<path:object_id>/', wrap(RedirectView.as_view(
pattern_name='%s:%s_%s_change' % ((self.admin_site.name,) + info)
))),
]
@@ -1173,8 +1173,7 @@ class ModelAdmin(BaseModelAdmin):
opts = obj._meta
to_field = request.POST.get(TO_FIELD_VAR)
attr = str(to_field) if to_field else opts.pk.attname
- # Retrieve the `object_id` from the resolved pattern arguments.
- value = request.resolver_match.args[0]
+ value = request.resolver_match.kwargs['object_id']
new_value = obj.serializable_value(attr)
popup_response_data = json.dumps({
'action': 'change',
diff --git a/django/contrib/admin/sites.py b/django/contrib/admin/sites.py
index c0767c15ee..2e37ade62e 100644
--- a/django/contrib/admin/sites.py
+++ b/django/contrib/admin/sites.py
@@ -196,11 +196,11 @@ class AdminSite:
class MyAdminSite(AdminSite):
def get_urls(self):
- from django.conf.urls import url
+ from django.urls import path
urls = super().get_urls()
urls += [
- url(r'^my_view/$', self.admin_view(some_view))
+ path('my_view/', self.admin_view(some_view))
]
return urls
@@ -230,7 +230,7 @@ class AdminSite:
return update_wrapper(inner, view)
def get_urls(self):
- from django.conf.urls import url, include
+ from django.urls import include, path, re_path
# Since this module gets imported in the application's root package,
# it cannot import models from other applications at the module level,
# and django.contrib.contenttypes.views imports ContentType.
@@ -244,15 +244,21 @@ class AdminSite:
# Admin-site-wide views.
urlpatterns = [
- url(r'^$', wrap(self.index), name='index'),
- url(r'^login/$', self.login, name='login'),
- url(r'^logout/$', wrap(self.logout), name='logout'),
- url(r'^password_change/$', wrap(self.password_change, cacheable=True), name='password_change'),
- url(r'^password_change/done/$', wrap(self.password_change_done, cacheable=True),
- name='password_change_done'),
- url(r'^jsi18n/$', wrap(self.i18n_javascript, cacheable=True), name='jsi18n'),
- url(r'^r/(?P<content_type_id>\d+)/(?P<object_id>.+)/$', wrap(contenttype_views.shortcut),
- name='view_on_site'),
+ path('', wrap(self.index), name='index'),
+ path('login/', self.login, name='login'),
+ path('logout/', wrap(self.logout), name='logout'),
+ path('password_change/', wrap(self.password_change, cacheable=True), name='password_change'),
+ path(
+ 'password_change/done/',
+ wrap(self.password_change_done, cacheable=True),
+ name='password_change_done',
+ ),
+ path('jsi18n/', wrap(self.i18n_javascript, cacheable=True), name='jsi18n'),
+ path(
+ 'r/<int:content_type_id>/<path:object_id>/',
+ wrap(contenttype_views.shortcut),
+ name='view_on_site',
+ ),
]
# Add in each model's views, and create a list of valid URLS for the
@@ -260,7 +266,7 @@ class AdminSite:
valid_app_labels = []
for model, model_admin in self._registry.items():
urlpatterns += [
- url(r'^%s/%s/' % (model._meta.app_label, model._meta.model_name), include(model_admin.urls)),
+ path('%s/%s/' % (model._meta.app_label, model._meta.model_name), include(model_admin.urls)),
]
if model._meta.app_label not in valid_app_labels:
valid_app_labels.append(model._meta.app_label)
@@ -270,7 +276,7 @@ class AdminSite:
if valid_app_labels:
regex = r'^(?P<app_label>' + '|'.join(valid_app_labels) + ')/$'
urlpatterns += [
- url(regex, wrap(self.app_index), name='app_list'),
+ re_path(regex, wrap(self.app_index), name='app_list'),
]
return urlpatterns
diff --git a/django/contrib/admindocs/urls.py b/django/contrib/admindocs/urls.py
index bfc9648e83..bc9c3df7cf 100644
--- a/django/contrib/admindocs/urls.py
+++ b/django/contrib/admindocs/urls.py
@@ -1,32 +1,50 @@
-from django.conf.urls import url
from django.contrib.admindocs import views
+from django.urls import path, re_path
urlpatterns = [
- url(r'^$',
+ path(
+ '',
views.BaseAdminDocsView.as_view(template_name='admin_doc/index.html'),
- name='django-admindocs-docroot'),
- url(r'^bookmarklets/$',
+ name='django-admindocs-docroot',
+ ),
+ path(
+ 'bookmarklets/',
views.BookmarkletsView.as_view(),
- name='django-admindocs-bookmarklets'),
- url(r'^tags/$',
+ name='django-admindocs-bookmarklets',
+ ),
+ path(
+ 'tags/',
views.TemplateTagIndexView.as_view(),
- name='django-admindocs-tags'),
- url(r'^filters/$',
+ name='django-admindocs-tags',
+ ),
+ path(
+ 'filters/',
views.TemplateFilterIndexView.as_view(),
- name='django-admindocs-filters'),
- url(r'^views/$',
+ name='django-admindocs-filters',
+ ),
+ path(
+ 'views/',
views.ViewIndexView.as_view(),
- name='django-admindocs-views-index'),
- url(r'^views/(?P<view>[^/]+)/$',
+ name='django-admindocs-views-index',
+ ),
+ path(
+ 'views/<view>/',
views.ViewDetailView.as_view(),
- name='django-admindocs-views-detail'),
- url(r'^models/$',
+ name='django-admindocs-views-detail',
+ ),
+ path(
+ 'models/',
views.ModelIndexView.as_view(),
- name='django-admindocs-models-index'),
- url(r'^models/(?P<app_label>[^\.]+)\.(?P<model_name>[^/]+)/$',
+ name='django-admindocs-models-index',
+ ),
+ re_path(
+ r'^models/(?P<app_label>[^\.]+)\.(?P<model_name>[^/]+)/$',
views.ModelDetailView.as_view(),
- name='django-admindocs-models-detail'),
- url(r'^templates/(?P<template>.*)/$',
+ name='django-admindocs-models-detail',
+ ),
+ path(
+ 'templates/<path:template>/',
views.TemplateDetailView.as_view(),
- name='django-admindocs-templates'),
+ name='django-admindocs-templates',
+ ),
]
diff --git a/django/contrib/admindocs/views.py b/django/contrib/admindocs/views.py
index 2b96ee3e8f..e45898c025 100644
--- a/django/contrib/admindocs/views.py
+++ b/django/contrib/admindocs/views.py
@@ -401,13 +401,12 @@ def extract_views_from_urlpatterns(urlpatterns, base='', namespace=None):
continue
views.extend(extract_views_from_urlpatterns(
patterns,
- base + p.regex.pattern,
+ base + str(p.pattern),
(namespace or []) + (p.namespace and [p.namespace] or [])
))
elif hasattr(p, 'callback'):
try:
- views.append((p.callback, base + p.regex.pattern,
- namespace, p.name))
+ views.append((p.callback, base + str(p.pattern), namespace, p.name))
except ViewDoesNotExist:
continue
else:
diff --git a/django/contrib/auth/admin.py b/django/contrib/auth/admin.py
index 3661d226a7..41df65a72e 100644
--- a/django/contrib/auth/admin.py
+++ b/django/contrib/auth/admin.py
@@ -1,5 +1,4 @@
from django.conf import settings
-from django.conf.urls import url
from django.contrib import admin, messages
from django.contrib.admin.options import IS_POPUP_VAR
from django.contrib.admin.utils import unquote
@@ -12,7 +11,7 @@ from django.core.exceptions import PermissionDenied
from django.db import router, transaction
from django.http import Http404, HttpResponseRedirect
from django.template.response import TemplateResponse
-from django.urls import reverse
+from django.urls import path, reverse
from django.utils.decorators import method_decorator
from django.utils.html import escape
from django.utils.translation import gettext, gettext_lazy as _
@@ -81,8 +80,8 @@ class UserAdmin(admin.ModelAdmin):
def get_urls(self):
return [
- url(
- r'^(.+)/password/$',
+ path(
+ '<id>/password/',
self.admin_site.admin_view(self.user_change_password),
name='auth_user_password_change',
),
diff --git a/django/contrib/auth/urls.py b/django/contrib/auth/urls.py
index 233eef8fec..c3306807e5 100644
--- a/django/contrib/auth/urls.py
+++ b/django/contrib/auth/urls.py
@@ -3,19 +3,18 @@
# It is also provided as a convenience to those who want to deploy these URLs
# elsewhere.
-from django.conf.urls import url
from django.contrib.auth import views
+from django.urls import path
urlpatterns = [
- url(r'^login/$', views.LoginView.as_view(), name='login'),
- url(r'^logout/$', views.LogoutView.as_view(), name='logout'),
+ path('login/', views.LoginView.as_view(), name='login'),
+ path('logout/', views.LogoutView.as_view(), name='logout'),
- url(r'^password_change/$', views.PasswordChangeView.as_view(), name='password_change'),
- url(r'^password_change/done/$', views.PasswordChangeDoneView.as_view(), name='password_change_done'),
+ path('password_change/', views.PasswordChangeView.as_view(), name='password_change'),
+ path('password_change/done/', views.PasswordChangeDoneView.as_view(), name='password_change_done'),
- url(r'^password_reset/$', views.PasswordResetView.as_view(), name='password_reset'),
- url(r'^password_reset/done/$', views.PasswordResetDoneView.as_view(), name='password_reset_done'),
- url(r'^reset/(?P<uidb64>[0-9A-Za-z_\-]+)/(?P<token>[0-9A-Za-z]{1,13}-[0-9A-Za-z]{1,20})/$',
- views.PasswordResetConfirmView.as_view(), name='password_reset_confirm'),
- url(r'^reset/done/$', views.PasswordResetCompleteView.as_view(), name='password_reset_complete'),
+ path('password_reset/', views.PasswordResetView.as_view(), name='password_reset'),
+ path('password_reset/done/', views.PasswordResetDoneView.as_view(), name='password_reset_done'),
+ path('reset/<uidb64>/<token>/', views.PasswordResetConfirmView.as_view(), name='password_reset_confirm'),
+ path('reset/done/', views.PasswordResetCompleteView.as_view(), name='password_reset_complete'),
]
diff --git a/django/contrib/flatpages/urls.py b/django/contrib/flatpages/urls.py
index 3437e823b2..a087fe8c1d 100644
--- a/django/contrib/flatpages/urls.py
+++ b/django/contrib/flatpages/urls.py
@@ -1,6 +1,6 @@
-from django.conf.urls import url
from django.contrib.flatpages import views
+from django.urls import path
urlpatterns = [
- url(r'^(?P<url>.*)$', views.flatpage, name='django.contrib.flatpages.views.flatpage'),
+ path('<path:url>', views.flatpage, name='django.contrib.flatpages.views.flatpage'),
]
diff --git a/django/core/checks/urls.py b/django/core/checks/urls.py
index 0f65d3f3b7..e51ca3fc1f 100644
--- a/django/core/checks/urls.py
+++ b/django/core/checks/urls.py
@@ -81,13 +81,13 @@ def get_warning_for_invalid_pattern(pattern):
"have a prefix string as the first element.".format(pattern)
)
elif isinstance(pattern, tuple):
- hint = "Try using url() instead of a tuple."
+ hint = "Try using path() instead of a tuple."
else:
hint = None
return [Error(
"Your URL pattern {!r} is invalid. Ensure that urlpatterns is a list "
- "of url() instances.".format(pattern),
+ "of path() and/or re_path() instances.".format(pattern),
hint=hint,
id="urls.E004",
)]
diff --git a/django/template/defaulttags.py b/django/template/defaulttags.py
index ad2cec3432..5f56bb0d1f 100644
--- a/django/template/defaulttags.py
+++ b/django/template/defaulttags.py
@@ -1329,7 +1329,7 @@ def url(parser, token):
{% url "url_name" name1=value1 name2=value2 %}
- The first argument is a django.conf.urls.url() name. Other arguments are
+ The first argument is a URL pattern name. Other arguments are
space-separated values that will be filled in place of positional and
keyword arguments in the URL. Don't mix positional and keyword arguments.
All arguments for the URL must be present.
@@ -1337,12 +1337,12 @@ def url(parser, token):
For example, if you have a view ``app_name.views.client_details`` taking
the client's id and the corresponding line in a URLconf looks like this::
- url('^client/(\d+)/$', views.client_details, name='client-detail-view')
+ path('client/<int:id>/', views.client_details, name='client-detail-view')
and this app's URLconf is included into the project's URLconf under some
path::
- url('^clients/', include('app_name.urls'))
+ path('clients/', include('app_name.urls'))
then in a template you can create a link for a certain client like this::
@@ -1359,7 +1359,7 @@ def url(parser, token):
"""
bits = token.split_contents()
if len(bits) < 2:
- raise TemplateSyntaxError("'%s' takes at least one argument, the name of a url()." % bits[0])
+ raise TemplateSyntaxError("'%s' takes at least one argument, a URL pattern name." % bits[0])
viewname = parser.compile_filter(bits[1])
args = []
kwargs = {}
diff --git a/django/urls/__init__.py b/django/urls/__init__.py
index f6d51e8a9f..e9e32ac5b9 100644
--- a/django/urls/__init__.py
+++ b/django/urls/__init__.py
@@ -3,19 +3,21 @@ from .base import (
is_valid_path, resolve, reverse, reverse_lazy, set_script_prefix,
set_urlconf, translate_url,
)
-from .conf import include
+from .conf import include, path, re_path
+from .converters import register_converter
from .exceptions import NoReverseMatch, Resolver404
from .resolvers import (
- LocaleRegexProvider, LocaleRegexURLResolver, RegexURLPattern,
- RegexURLResolver, ResolverMatch, get_ns_resolver, get_resolver,
+ LocalePrefixPattern, ResolverMatch, URLPattern, URLResolver,
+ get_ns_resolver, get_resolver,
)
from .utils import get_callable, get_mod_func
__all__ = [
- 'LocaleRegexProvider', 'LocaleRegexURLResolver', 'NoReverseMatch',
- 'RegexURLPattern', 'RegexURLResolver', 'Resolver404', 'ResolverMatch',
- 'clear_script_prefix', 'clear_url_caches', 'get_callable', 'get_mod_func',
- 'get_ns_resolver', 'get_resolver', 'get_script_prefix', 'get_urlconf',
- 'include', 'is_valid_path', 'resolve', 'reverse', 'reverse_lazy',
- 'set_script_prefix', 'set_urlconf', 'translate_url',
+ 'LocalePrefixPattern', 'NoReverseMatch', 'URLPattern',
+ 'URLResolver', 'Resolver404', 'ResolverMatch', 'clear_script_prefix',
+ 'clear_url_caches', 'get_callable', 'get_mod_func', 'get_ns_resolver',
+ 'get_resolver', 'get_script_prefix', 'get_urlconf', 'include',
+ 'is_valid_path', 'path', 're_path', 'register_converter', 'resolve',
+ 'reverse', 'reverse_lazy', 'set_script_prefix', 'set_urlconf',
+ 'translate_url',
]
diff --git a/django/urls/conf.py b/django/urls/conf.py
index 99bff55819..119e95df41 100644
--- a/django/urls/conf.py
+++ b/django/urls/conf.py
@@ -1,9 +1,12 @@
"""Functions for use in URLsconfs."""
+from functools import partial
from importlib import import_module
from django.core.exceptions import ImproperlyConfigured
-from .resolvers import LocaleRegexURLResolver
+from .resolvers import (
+ LocalePrefixPattern, RegexPattern, RoutePattern, URLPattern, URLResolver,
+)
def include(arg, namespace=None):
@@ -43,8 +46,32 @@ def include(arg, namespace=None):
# testcases will break).
if isinstance(patterns, (list, tuple)):
for url_pattern in patterns:
- if isinstance(url_pattern, LocaleRegexURLResolver):
+ pattern = getattr(url_pattern, 'pattern', None)
+ if isinstance(pattern, LocalePrefixPattern):
raise ImproperlyConfigured(
'Using i18n_patterns in an included URLconf is not allowed.'
)
return (urlconf_module, app_name, namespace)
+
+
+def _path(route, view, kwargs=None, name=None, Pattern=None):
+ if isinstance(view, (list, tuple)):
+ # For include(...) processing.
+ pattern = Pattern(route, is_endpoint=False)
+ urlconf_module, app_name, namespace = view
+ return URLResolver(
+ pattern,
+ urlconf_module,
+ kwargs,
+ app_name=app_name,
+ namespace=namespace,
+ )
+ elif callable(view):
+ pattern = Pattern(route, name=name, is_endpoint=True)
+ return URLPattern(pattern, view, kwargs, name)
+ else:
+ raise TypeError('view must be a callable or a list/tuple in the case of include().')
+
+
+path = partial(_path, Pattern=RoutePattern)
+re_path = partial(_path, Pattern=RegexPattern)
diff --git a/django/urls/converters.py b/django/urls/converters.py
new file mode 100644
index 0000000000..eb2a61971e
--- /dev/null
+++ b/django/urls/converters.py
@@ -0,0 +1,70 @@
+import uuid
+
+from django.utils import lru_cache
+
+
+class IntConverter:
+ regex = '[0-9]+'
+
+ def to_python(self, value):
+ return int(value)
+
+ def to_url(self, value):
+ return str(value)
+
+
+class StringConverter:
+ regex = '[^/]+'
+
+ def to_python(self, value):
+ return value
+
+ def to_url(self, value):
+ return value
+
+
+class UUIDConverter:
+ regex = '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}'
+
+ def to_python(self, value):
+ return uuid.UUID(value)
+
+ def to_url(self, value):
+ return str(value)
+
+
+class SlugConverter(StringConverter):
+ regex = '[-a-zA-Z0-9_]+'
+
+
+class PathConverter(StringConverter):
+ regex = '.+'
+
+
+DEFAULT_CONVERTERS = {
+ 'int': IntConverter(),
+ 'path': PathConverter(),
+ 'slug': SlugConverter(),
+ 'str': StringConverter(),
+ 'uuid': UUIDConverter(),
+}
+
+
+REGISTERED_CONVERTERS = {}
+
+
+def register_converter(converter, type_name):
+ REGISTERED_CONVERTERS[type_name] = converter()
+ get_converters.cache_clear()
+
+
+@lru_cache.lru_cache(maxsize=None)
+def get_converters():
+ converters = {}
+ converters.update(DEFAULT_CONVERTERS)
+ converters.update(REGISTERED_CONVERTERS)
+ return converters
+
+
+def get_converter(raw_converter):
+ return get_converters()[raw_converter]
diff --git a/django/urls/resolvers.py b/django/urls/resolvers.py
index ecad10acea..f91e821b38 100644
--- a/django/urls/resolvers.py
+++ b/django/urls/resolvers.py
@@ -21,6 +21,7 @@ from django.utils.http import RFC3986_SUBDELIMS
from django.utils.regex_helper import normalize
from django.utils.translation import get_language
+from .converters import get_converter
from .exceptions import NoReverseMatch, Resolver404
from .utils import get_callable
@@ -64,7 +65,7 @@ def get_resolver(urlconf=None):
if urlconf is None:
from django.conf import settings
urlconf = settings.ROOT_URLCONF
- return RegexURLResolver(r'^/', urlconf)
+ return URLResolver(RegexPattern(r'^/'), urlconf)
@functools.lru_cache(maxsize=None)
@@ -72,11 +73,14 @@ def get_ns_resolver(ns_pattern, resolver):
# Build a namespaced resolver for the given parent URLconf pattern.
# This makes it possible to have captured parameters in the parent
# URLconf pattern.
- ns_resolver = RegexURLResolver(ns_pattern, resolver.url_patterns)
- return RegexURLResolver(r'^/', [ns_resolver])
+ ns_resolver = URLResolver(RegexPattern(ns_pattern), resolver.url_patterns)
+ return URLResolver(RegexPattern(r'^/'), [ns_resolver])
class LocaleRegexDescriptor:
+ def __init__(self, attr):
+ self.attr = attr
+
def __get__(self, instance, cls=None):
"""
Return a compiled regular expression based on the active language.
@@ -86,46 +90,23 @@ class LocaleRegexDescriptor:
# As a performance optimization, if the given regex string is a regular
# string (not a lazily-translated string proxy), compile it once and
# avoid per-language compilation.
- if isinstance(instance._regex, str):
- instance.__dict__['regex'] = self._compile(instance._regex)
+ pattern = getattr(instance, self.attr)
+ if isinstance(pattern, str):
+ instance.__dict__['regex'] = instance._compile(pattern)
return instance.__dict__['regex']
language_code = get_language()
if language_code not in instance._regex_dict:
- instance._regex_dict[language_code] = self._compile(str(instance._regex))
+ instance._regex_dict[language_code] = instance._compile(str(pattern))
return instance._regex_dict[language_code]
- def _compile(self, regex):
- """
- Compile and return the given regular expression.
- """
- try:
- return re.compile(regex)
- except re.error as e:
- raise ImproperlyConfigured(
- '"%s" is not a valid regular expression: %s' % (regex, e)
- )
-
-
-class LocaleRegexProvider:
- """
- A mixin to provide a default regex property which can vary by active
- language.
- """
- def __init__(self, regex):
- # regex is either a string representing a regular expression, or a
- # translatable string (using gettext_lazy) representing a regular
- # expression.
- self._regex = regex
- self._regex_dict = {}
-
- regex = LocaleRegexDescriptor()
+class CheckURLMixin:
def describe(self):
"""
Format the URL pattern for display in warning messages.
"""
- description = "'{}'".format(self.regex.pattern)
- if getattr(self, 'name', False):
+ description = "'{}'".format(self)
+ if self.name:
description += " [name='{}']".format(self.name)
return description
@@ -138,9 +119,9 @@ class LocaleRegexProvider:
# Skip check as it can be useful to start a URL pattern with a slash
# when APPEND_SLASH=False.
return []
- if (regex_pattern.startswith('/') or regex_pattern.startswith('^/')) and not regex_pattern.endswith('/'):
+ if any(regex_pattern.startswith(x) for x in ('/', '^/', '^\/')) and not regex_pattern.endswith('/'):
warning = Warning(
- "Your URL pattern {} has a regex beginning with a '/'. Remove this "
+ "Your URL pattern {} has a route beginning with a '/'. Remove this "
"slash as it is unnecessary. If this pattern is targeted in an "
"include(), ensure the include() pattern has a trailing '/'.".format(
self.describe()
@@ -152,30 +133,195 @@ class LocaleRegexProvider:
return []
-class RegexURLPattern(LocaleRegexProvider):
- def __init__(self, regex, callback, default_args=None, name=None):
- LocaleRegexProvider.__init__(self, regex)
+class RegexPattern(CheckURLMixin):
+ regex = LocaleRegexDescriptor('_regex')
+
+ def __init__(self, regex, name=None, is_endpoint=False):
+ self._regex = regex
+ self._regex_dict = {}
+ self._is_endpoint = is_endpoint
+ self.name = name
+ self.converters = {}
+
+ def match(self, path):
+ match = self.regex.search(path)
+ if match:
+ # If there are any named groups, use those as kwargs, ignoring
+ # non-named groups. Otherwise, pass all non-named arguments as
+ # positional arguments.
+ kwargs = match.groupdict()
+ args = () if kwargs else match.groups()
+ return path[match.end():], args, kwargs
+ return None
+
+ def check(self):
+ warnings = []
+ warnings.extend(self._check_pattern_startswith_slash())
+ if not self._is_endpoint:
+ warnings.extend(self._check_include_trailing_dollar())
+ return warnings
+
+ def _check_include_trailing_dollar(self):
+ regex_pattern = self.regex.pattern
+ if regex_pattern.endswith('$') and not regex_pattern.endswith(r'\$'):
+ return [Warning(
+ "Your URL pattern {} uses include with a route ending with a '$'. "
+ "Remove the dollar from the route to avoid problems including "
+ "URLs.".format(self.describe()),
+ id='urls.W001',
+ )]
+ else:
+ return []
+
+ def _compile(self, regex):
+ """Compile and return the given regular expression."""
+ try:
+ return re.compile(regex)
+ except re.error as e:
+ raise ImproperlyConfigured(
+ '"%s" is not a valid regular expression: %s' % (regex, e)
+ )
+
+ def __str__(self):
+ return self._regex
+
+
+_PATH_PARAMETER_COMPONENT_RE = re.compile(
+ '<(?:(?P<converter>[^>:]+):)?(?P<parameter>\w+)>'
+)
+
+
+def _route_to_regex(route, is_endpoint=False):
+ """
+ Convert a path pattern into a regular expression. Return the regular
+ expression and a dictionary mapping the capture names to the converters.
+ For example, 'foo/<int:pk>' returns '^foo\\/(?P<pk>[0-9]+)'
+ and {'pk': <django.urls.converters.IntConverter>}.
+ """
+ original_route = route
+ parts = ['^']
+ converters = {}
+ while True:
+ match = _PATH_PARAMETER_COMPONENT_RE.search(route)
+ if not match:
+ parts.append(re.escape(route))
+ break
+ parts.append(re.escape(route[:match.start()]))
+ route = route[match.end():]
+ parameter = match.group('parameter')
+ if not parameter.isidentifier():
+ raise ImproperlyConfigured(
+ "URL route '%s' uses parameter name %r which isn't a valid "
+ "Python identifier." % (original_route, parameter)
+ )
+ raw_converter = match.group('converter')
+ if raw_converter is None:
+ # If a converter isn't specified, the default is `str`.
+ raw_converter = 'str'
+ try:
+ converter = get_converter(raw_converter)
+ except KeyError as e:
+ raise ImproperlyConfigured(
+ "URL route '%s' uses invalid converter %s." % (original_route, e)
+ )
+ converters[parameter] = converter
+ parts.append('(?P<' + parameter + '>' + converter.regex + ')')
+ if is_endpoint:
+ parts.append('$')
+ return ''.join(parts), converters
+
+
+class RoutePattern(CheckURLMixin):
+ regex = LocaleRegexDescriptor('_route')
+
+ def __init__(self, route, name=None, is_endpoint=False):
+ self._route = route
+ self._regex_dict = {}
+ self._is_endpoint = is_endpoint
+ self.name = name
+ self.converters = _route_to_regex(str(route), is_endpoint)[1]
+
+ def match(self, path):
+ match = self.regex.search(path)
+ if match:
+ # RoutePattern doesn't allow non-named groups so args are ignored.
+ kwargs = match.groupdict()
+ for key, value in kwargs.items():
+ converter = self.converters[key]
+ try:
+ kwargs[key] = converter.to_python(value)
+ except ValueError:
+ return None
+ return path[match.end():], (), kwargs
+ return None
+
+ def check(self):
+ return self._check_pattern_startswith_slash()
+
+ def _compile(self, route):
+ return re.compile(_route_to_regex(route, self._is_endpoint)[0])
+
+ def __str__(self):
+ return self._route
+
+
+class LocalePrefixPattern:
+ def __init__(self, prefix_default_language=True):
+ self.prefix_default_language = prefix_default_language
+ self.converters = {}
+
+ @property
+ def regex(self):
+ # This is only used by reverse() and cached in _reverse_dict.
+ return re.compile(self.language_prefix)
+
+ @property
+ def language_prefix(self):
+ language_code = get_language() or settings.LANGUAGE_CODE
+ if language_code == settings.LANGUAGE_CODE and not self.prefix_default_language:
+ return ''
+ else:
+ return '%s/' % language_code
+
+ def match(self, path):
+ language_prefix = self.language_prefix
+ if path.startswith(language_prefix):
+ return path[len(language_prefix):], (), {}
+ return None
+
+ def check(self):
+ return []
+
+ def describe(self):
+ return "'{}'".format(self)
+
+ def __str__(self):
+ return self.language_prefix
+
+
+class URLPattern:
+ def __init__(self, pattern, callback, default_args=None, name=None):
+ self.pattern = pattern
self.callback = callback # the view
self.default_args = default_args or {}
self.name = name
def __repr__(self):
- return '<%s %s %s>' % (self.__class__.__name__, self.name, self.regex.pattern)
+ return '<%s %s>' % (self.__class__.__name__, self.pattern.describe())
def check(self):
warnings = self._check_pattern_name()
- if not warnings:
- warnings = self._check_pattern_startswith_slash()
+ warnings.extend(self.pattern.check())
return warnings
def _check_pattern_name(self):
"""
Check that the pattern name does not contain a colon.
"""
- if self.name is not None and ":" in self.name:
+ if self.pattern.name is not None and ":" in self.pattern.name:
warning = Warning(
"Your URL pattern {} has a name including a ':'. Remove the colon, to "
- "avoid ambiguous namespace references.".format(self.describe()),
+ "avoid ambiguous namespace references.".format(self.pattern.describe()),
id="urls.W003",
)
return [warning]
@@ -183,16 +329,12 @@ class RegexURLPattern(LocaleRegexProvider):
return []
def resolve(self, path):
- match = self.regex.search(path)
+ match = self.pattern.match(path)
if match:
- # If there are any named groups, use those as kwargs, ignoring
- # non-named groups. Otherwise, pass all non-named arguments as
- # positional arguments.
- kwargs = match.groupdict()
- args = () if kwargs else match.groups()
- # In both cases, pass any extra_kwargs as **kwargs.
+ new_path, args, kwargs = match
+ # Pass any extra_kwargs as **kwargs.
kwargs.update(self.default_args)
- return ResolverMatch(self.callback, args, kwargs, self.name)
+ return ResolverMatch(self.callback, args, kwargs, self.pattern.name)
@cached_property
def lookup_str(self):
@@ -210,9 +352,9 @@ class RegexURLPattern(LocaleRegexProvider):
return callback.__module__ + "." + callback.__qualname__
-class RegexURLResolver(LocaleRegexProvider):
- def __init__(self, regex, urlconf_name, default_kwargs=None, app_name=None, namespace=None):
- LocaleRegexProvider.__init__(self, regex)
+class URLResolver:
+ def __init__(self, pattern, urlconf_name, default_kwargs=None, app_name=None, namespace=None):
+ self.pattern = pattern
# urlconf_name is the dotted Python path to the module defining
# urlpatterns. It may also be an object with an urlpatterns attribute
# or urlpatterns itself.
@@ -238,33 +380,17 @@ class RegexURLResolver(LocaleRegexProvider):
urlconf_repr = repr(self.urlconf_name)
return '<%s %s (%s:%s) %s>' % (
self.__class__.__name__, urlconf_repr, self.app_name,
- self.namespace, self.regex.pattern,
+ self.namespace, self.pattern.describe(),
)
def check(self):
- warnings = self._check_include_trailing_dollar()
+ warnings = []
for pattern in self.url_patterns:
warnings.extend(check_resolver(pattern))
if not warnings:
- warnings = self._check_pattern_startswith_slash()
+ warnings = self.pattern.check()
return warnings
- def _check_include_trailing_dollar(self):
- """
- Check that include is not used with a regex ending with a dollar.
- """
- regex_pattern = self.regex.pattern
- if regex_pattern.endswith('$') and not regex_pattern.endswith(r'\$'):
- warning = Warning(
- "Your URL pattern {} uses include with a regex ending with a '$'. "
- "Remove the dollar from the regex to avoid problems including "
- "URLs.".format(self.describe()),
- id="urls.W001",
- )
- return [warning]
- else:
- return []
-
def _populate(self):
# Short-circuit if called recursively in this thread to prevent
# infinite recursion. Concurrent threads may call this at the same
@@ -277,47 +403,52 @@ class RegexURLResolver(LocaleRegexProvider):
namespaces = {}
apps = {}
language_code = get_language()
- for pattern in reversed(self.url_patterns):
- if isinstance(pattern, RegexURLPattern):
- self._callback_strs.add(pattern.lookup_str)
- p_pattern = pattern.regex.pattern
- if p_pattern.startswith('^'):
- p_pattern = p_pattern[1:]
- if isinstance(pattern, RegexURLResolver):
- if pattern.namespace:
- namespaces[pattern.namespace] = (p_pattern, pattern)
- if pattern.app_name:
- apps.setdefault(pattern.app_name, []).append(pattern.namespace)
- else:
- parent_pat = pattern.regex.pattern
- for name in pattern.reverse_dict:
- for matches, pat, defaults in pattern.reverse_dict.getlist(name):
- new_matches = normalize(parent_pat + pat)
- lookups.appendlist(
- name,
- (
- new_matches,
- p_pattern + pat,
- dict(defaults, **pattern.default_kwargs),
+ try:
+ for url_pattern in reversed(self.url_patterns):
+ p_pattern = url_pattern.pattern.regex.pattern
+ if p_pattern.startswith('^'):
+ p_pattern = p_pattern[1:]
+ if isinstance(url_pattern, URLPattern):
+ self._callback_strs.add(url_pattern.lookup_str)
+ bits = normalize(url_pattern.pattern.regex.pattern)
+ lookups.appendlist(
+ url_pattern.callback,
+ (bits, p_pattern, url_pattern.default_args, url_pattern.pattern.converters)
+ )
+ if url_pattern.name is not None:
+ lookups.appendlist(
+ url_pattern.name,
+ (bits, p_pattern, url_pattern.default_args, url_pattern.pattern.converters)
+ )
+ else: # url_pattern is a URLResolver.
+ url_pattern._populate()
+ if url_pattern.app_name:
+ apps.setdefault(url_pattern.app_name, []).append(url_pattern.namespace)
+ namespaces[url_pattern.namespace] = (p_pattern, url_pattern)
+ else:
+ for name in url_pattern.reverse_dict:
+ for matches, pat, defaults, converters in url_pattern.reverse_dict.getlist(name):
+ new_matches = normalize(p_pattern + pat)
+ lookups.appendlist(
+ name,
+ (
+ new_matches,
+ p_pattern + pat,
+ dict(defaults, **url_pattern.default_kwargs),
+ dict(self.pattern.converters, **converters)
+ )
)
- )
- for namespace, (prefix, sub_pattern) in pattern.namespace_dict.items():
- namespaces[namespace] = (p_pattern + prefix, sub_pattern)
- for app_name, namespace_list in pattern.app_dict.items():
- apps.setdefault(app_name, []).extend(namespace_list)
- if not getattr(pattern._local, 'populating', False):
- pattern._populate()
- self._callback_strs.update(pattern._callback_strs)
- else:
- bits = normalize(p_pattern)
- lookups.appendlist(pattern.callback, (bits, p_pattern, pattern.default_args))
- if pattern.name is not None:
- lookups.appendlist(pattern.name, (bits, p_pattern, pattern.default_args))
- self._reverse_dict[language_code] = lookups
- self._namespace_dict[language_code] = namespaces
- self._app_dict[language_code] = apps
- self._populated = True
- self._local.populating = False
+ for namespace, (prefix, sub_pattern) in url_pattern.namespace_dict.items():
+ namespaces[namespace] = (p_pattern + prefix, sub_pattern)
+ for app_name, namespace_list in url_pattern.app_dict.items():
+ apps.setdefault(app_name, []).extend(namespace_list)
+ self._callback_strs.update(url_pattern._callback_strs)
+ self._namespace_dict[language_code] = namespaces
+ self._app_dict[language_code] = apps
+ self._reverse_dict[language_code] = lookups
+ self._populated = True
+ finally:
+ self._local.populating = False
@property
def reverse_dict(self):
@@ -348,9 +479,9 @@ class RegexURLResolver(LocaleRegexProvider):
def resolve(self, path):
path = str(path) # path may be a reverse_lazy object
tried = []
- match = self.regex.search(path)
+ match = self.pattern.match(path)
if match:
- new_path = path[match.end():]
+ new_path, args, kwargs = match
for pattern in self.url_patterns:
try:
sub_match = pattern.resolve(new_path)
@@ -363,15 +494,14 @@ class RegexURLResolver(LocaleRegexProvider):
else:
if sub_match:
# Merge captured arguments in match with submatch
- sub_match_dict = dict(match.groupdict(), **self.default_kwargs)
+ sub_match_dict = dict(kwargs, **self.default_kwargs)
+ # Update the sub_match_dict with the kwargs from the sub_match.
sub_match_dict.update(sub_match.kwargs)
-
# If there are *any* named groups, ignore all non-named groups.
# Otherwise, pass all non-named arguments as positional arguments.
sub_match_args = sub_match.args
if not sub_match_dict:
- sub_match_args = match.groups() + sub_match.args
-
+ sub_match_args = args + sub_match.args
return ResolverMatch(
sub_match.func,
sub_match_args,
@@ -421,20 +551,18 @@ class RegexURLResolver(LocaleRegexProvider):
def _reverse_with_prefix(self, lookup_view, _prefix, *args, **kwargs):
if args and kwargs:
raise ValueError("Don't mix *args and **kwargs in call to reverse()!")
- text_args = [str(v) for v in args]
- text_kwargs = {k: str(v) for (k, v) in kwargs.items()}
if not self._populated:
self._populate()
possibilities = self.reverse_dict.getlist(lookup_view)
- for possibility, pattern, defaults in possibilities:
+ for possibility, pattern, defaults, converters in possibilities:
for result, params in possibility:
if args:
if len(args) != len(params):
continue
- candidate_subs = dict(zip(params, text_args))
+ candidate_subs = dict(zip(params, args))
else:
if set(kwargs).symmetric_difference(params).difference(defaults):
continue
@@ -445,16 +573,23 @@ class RegexURLResolver(LocaleRegexProvider):
break
if not matches:
continue
- candidate_subs = text_kwargs
+ candidate_subs = kwargs
+ # Convert the candidate subs to text using Converter.to_url().
+ text_candidate_subs = {}
+ for k, v in candidate_subs.items():
+ if k in converters:
+ text_candidate_subs[k] = converters[k].to_url(v)
+ else:
+ text_candidate_subs[k] = str(v)
# WSGI provides decoded URLs, without %xx escapes, and the URL
# resolver operates on such URLs. First substitute arguments
# without quoting to build a decoded URL and look for a match.
# Then, if we have a match, redo the substitution with quoted
# arguments in order to return a properly encoded URL.
candidate_pat = _prefix.replace('%', '%%') + result
- if re.search('^%s%s' % (re.escape(_prefix), pattern), candidate_pat % candidate_subs):
+ if re.search('^%s%s' % (re.escape(_prefix), pattern), candidate_pat % text_candidate_subs):
# safe characters from `pchar` definition of RFC 3986
- url = quote(candidate_pat % candidate_subs, safe=RFC3986_SUBDELIMS + '/~:@')
+ url = quote(candidate_pat % text_candidate_subs, safe=RFC3986_SUBDELIMS + '/~:@')
# Don't allow construction of scheme relative urls.
if url.startswith('//'):
url = '/%%2F%s' % url[2:]
@@ -468,7 +603,7 @@ class RegexURLResolver(LocaleRegexProvider):
else:
lookup_view_s = lookup_view
- patterns = [pattern for (possibility, pattern, defaults) in possibilities]
+ patterns = [pattern for (_, pattern, _, _) in possibilities]
if patterns:
if args:
arg_msg = "arguments '%s'" % (args,)
@@ -486,29 +621,3 @@ class RegexURLResolver(LocaleRegexProvider):
"a valid view function or pattern name." % {'view': lookup_view_s}
)
raise NoReverseMatch(msg)
-
-
-class LocaleRegexURLResolver(RegexURLResolver):
- """
- A URL resolver that always matches the active language code as URL prefix.
-
- Rather than taking a regex argument, we just override the ``regex``
- function to always return the active language-code as regex.
- """
- def __init__(
- self, urlconf_name, default_kwargs=None, app_name=None, namespace=None,
- prefix_default_language=True,
- ):
- super().__init__(None, urlconf_name, default_kwargs, app_name, namespace)
- self.prefix_default_language = prefix_default_language
-
- @property
- def regex(self):
- language_code = get_language() or settings.LANGUAGE_CODE
- if language_code not in self._regex_dict:
- if language_code == settings.LANGUAGE_CODE and not self.prefix_default_language:
- regex_string = ''
- else:
- regex_string = '^%s/' % language_code
- self._regex_dict[language_code] = re.compile(regex_string)
- return self._regex_dict[language_code]
diff --git a/django/views/generic/dates.py b/django/views/generic/dates.py
index 5e0247474d..2aedf3aacd 100644
--- a/django/views/generic/dates.py
+++ b/django/views/generic/dates.py
@@ -612,7 +612,7 @@ def _date_from_string(year, year_format, month='', month_format='', day='', day_
(only year is mandatory). Raise a 404 for an invalid date.
"""
format = year_format + delim + month_format + delim + day_format
- datestr = year + delim + month + delim + day
+ datestr = str(year) + delim + str(month) + delim + str(day)
try:
return datetime.datetime.strptime(datestr, format).date()
except ValueError:
diff --git a/django/views/templates/technical_404.html b/django/views/templates/technical_404.html
index ec518f658e..0bf5fd3cd0 100644
--- a/django/views/templates/technical_404.html
+++ b/django/views/templates/technical_404.html
@@ -52,7 +52,7 @@
{% for pattern in urlpatterns %}
<li>
{% for pat in pattern %}
- {{ pat.regex.pattern }}
+ {{ pat.pattern }}
{% if forloop.last and pat.name %}[name='{{ pat.name }}']{% endif %}
{% endfor %}
</li>