URLs
Route requests to views.
URLs are typically the "entrypoint" to your app. Virtually all request handling up to this point happens behind the scenes, and then you decide how to route specific URL patterns to your views.
The URLS_ROUTER
is the primary router that handles all incoming requests. It is defined in your app/settings.py
file. This will typically point to a Router
class in your app.urls
module.
# app/settings.py
URLS_ROUTER = "app.urls.AppRouter"
The root router often has an empty namespace (""
) and some combination of individual paths and sub-routers.
# app/urls.py
from plain.urls import Router, path, include
from plain.admin.urls import AdminRouter
from . import views
class AppRouter(Router):
namespace = ""
urls = [
include("admin/", AdminRouter),
path("about/", views.AboutView, name="about"), # A named URL
path("", views.HomeView), # An unnamed URL
]
Reversing URLs
In templates, you will use the {{ url("<url name>") }}
function to look up full URLs by name.
<a href="{{ url('about') }}">About</a>
And the same can be done in Python code with the reverse
(or reverse_lazy
) function.
from plain.urls import reverse
url = reverse("about")
A URL path has to include a name
attribute if you want to reverse it. The router's namespace
will be used as a prefix to the URL name.
from plain.urls import reverse
url = reverse("admin:dashboard")
URL args and kwargs
URL patterns can include arguments and keyword arguments.
# app/urls.py
from plain.urls import Router, path
from . import views
class AppRouter(Router):
namespace = ""
urls = [
path("user/<int:user_id>/", views.UserView, name="user"),
path("search/<str:query>/", views.SearchView, name="search"),
]
These will be accessible inside the view as self.url_args
and self.url_kwargs
.
# app/views.py
from plain.views import View
class SearchView(View):
def get(self):
query = self.url_kwargs["query"]
print(f"Searching for {query}")
# ...
To reverse a URL with args or kwargs, simply pass them in the reverse
function.
from plain.urls import reverse
url = reverse("search", query="example")
There are a handful of built-in converters that can be used in URL patterns.
from plain.urls import Router, path
from . import views
class AppRouter(Router):
namespace = ""
urls = [
path("user/<int:user_id>/", views.UserView, name="user"),
path("search/<str:query>/", views.SearchView, name="search"),
path("post/<slug:post_slug>/", views.PostView, name="post"),
path("document/<uuid:uuid>/", views.DocumentView, name="document"),
path("path/<path:subpath>/", views.PathView, name="path"),
]
Package routers
Installed packages will often provide a URL router to include in your root URL router.
# plain/assets/urls.py
from plain.urls import Router, path
from .views import AssetView
class AssetsRouter(Router):
"""
The router for serving static assets.
Include this router in your app router if you are serving assets yourself.
"""
namespace = "assets"
urls = [
path("<path:path>", AssetView, name="asset"),
]
Import the package's router and include
it at any path you choose.
from plain.urls import include, Router
from plain.assets.urls import AssetsRouter
class AppRouter(Router):
namespace = ""
urls = [
include("assets/", AssetsRouter),
# Your other URLs here...
]
1import functools
2import inspect
3import re
4import string
5
6from plain.exceptions import ImproperlyConfigured
7from plain.internal import internalcode
8from plain.preflight import Error, Warning
9from plain.runtime import settings
10from plain.utils.functional import cached_property
11from plain.utils.regex_helper import _lazy_re_compile
12
13from .converters import get_converter
14
15
16@internalcode
17class CheckURLMixin:
18 def describe(self):
19 """
20 Format the URL pattern for display in warning messages.
21 """
22 description = f"'{self}'"
23 if self.name:
24 description += f" [name='{self.name}']"
25 return description
26
27 def _check_pattern_startswith_slash(self):
28 """
29 Check that the pattern does not begin with a forward slash.
30 """
31 regex_pattern = self.regex.pattern
32 if not settings.APPEND_SLASH:
33 # Skip check as it can be useful to start a URL pattern with a slash
34 # when APPEND_SLASH=False.
35 return []
36 if regex_pattern.startswith(("/", "^/", "^\\/")) and not regex_pattern.endswith(
37 "/"
38 ):
39 warning = Warning(
40 f"Your URL pattern {self.describe()} has a route beginning with a '/'. Remove this "
41 "slash as it is unnecessary. If this pattern is targeted in an "
42 "include(), ensure the include() pattern has a trailing '/'.",
43 id="urls.W002",
44 )
45 return [warning]
46 else:
47 return []
48
49
50class RegexPattern(CheckURLMixin):
51 def __init__(self, regex, name=None, is_endpoint=False):
52 self._regex = regex
53 self._is_endpoint = is_endpoint
54 self.name = name
55 self.converters = {}
56 self.regex = self._compile(str(regex))
57
58 def match(self, path):
59 match = (
60 self.regex.fullmatch(path)
61 if self._is_endpoint and self.regex.pattern.endswith("$")
62 else self.regex.search(path)
63 )
64 if match:
65 # If there are any named groups, use those as kwargs, ignoring
66 # non-named groups. Otherwise, pass all non-named arguments as
67 # positional arguments.
68 kwargs = match.groupdict()
69 args = () if kwargs else match.groups()
70 kwargs = {k: v for k, v in kwargs.items() if v is not None}
71 return path[match.end() :], args, kwargs
72 return None
73
74 def check(self):
75 warnings = []
76 warnings.extend(self._check_pattern_startswith_slash())
77 if not self._is_endpoint:
78 warnings.extend(self._check_include_trailing_dollar())
79 return warnings
80
81 def _check_include_trailing_dollar(self):
82 regex_pattern = self.regex.pattern
83 if regex_pattern.endswith("$") and not regex_pattern.endswith(r"\$"):
84 return [
85 Warning(
86 f"Your URL pattern {self.describe()} uses include with a route ending with a '$'. "
87 "Remove the dollar from the route to avoid problems including "
88 "URLs.",
89 id="urls.W001",
90 )
91 ]
92 else:
93 return []
94
95 def _compile(self, regex):
96 """Compile and return the given regular expression."""
97 try:
98 return re.compile(regex)
99 except re.error as e:
100 raise ImproperlyConfigured(
101 f'"{regex}" is not a valid regular expression: {e}'
102 ) from e
103
104 def __str__(self):
105 return str(self._regex)
106
107
108_PATH_PARAMETER_COMPONENT_RE = _lazy_re_compile(
109 r"<(?:(?P<converter>[^>:]+):)?(?P<parameter>[^>]+)>"
110)
111
112
113def _route_to_regex(route, is_endpoint=False):
114 """
115 Convert a path pattern into a regular expression. Return the regular
116 expression and a dictionary mapping the capture names to the converters.
117 For example, 'foo/<int:pk>' returns '^foo\\/(?P<pk>[0-9]+)'
118 and {'pk': <plain.urls.converters.IntConverter>}.
119 """
120 original_route = route
121 parts = ["^"]
122 converters = {}
123 while True:
124 match = _PATH_PARAMETER_COMPONENT_RE.search(route)
125 if not match:
126 parts.append(re.escape(route))
127 break
128 elif not set(match.group()).isdisjoint(string.whitespace):
129 raise ImproperlyConfigured(
130 f"URL route '{original_route}' cannot contain whitespace in angle brackets "
131 "<…>."
132 )
133 parts.append(re.escape(route[: match.start()]))
134 route = route[match.end() :]
135 parameter = match["parameter"]
136 if not parameter.isidentifier():
137 raise ImproperlyConfigured(
138 f"URL route '{original_route}' uses parameter name {parameter!r} which isn't a valid "
139 "Python identifier."
140 )
141 raw_converter = match["converter"]
142 if raw_converter is None:
143 # If a converter isn't specified, the default is `str`.
144 raw_converter = "str"
145 try:
146 converter = get_converter(raw_converter)
147 except KeyError as e:
148 raise ImproperlyConfigured(
149 f"URL route {original_route!r} uses invalid converter {raw_converter!r}."
150 ) from e
151 converters[parameter] = converter
152 parts.append("(?P<" + parameter + ">" + converter.regex + ")")
153 if is_endpoint:
154 parts.append(r"\Z")
155 return "".join(parts), converters
156
157
158class RoutePattern(CheckURLMixin):
159 def __init__(self, route, name=None, is_endpoint=False):
160 self._route = route
161 self._is_endpoint = is_endpoint
162 self.name = name
163 self.converters = _route_to_regex(str(route), is_endpoint)[1]
164 self.regex = self._compile(str(route))
165
166 def match(self, path):
167 match = self.regex.search(path)
168 if match:
169 # RoutePattern doesn't allow non-named groups so args are ignored.
170 kwargs = match.groupdict()
171 for key, value in kwargs.items():
172 converter = self.converters[key]
173 try:
174 kwargs[key] = converter.to_python(value)
175 except ValueError:
176 return None
177 return path[match.end() :], (), kwargs
178 return None
179
180 def check(self):
181 warnings = self._check_pattern_startswith_slash()
182 route = self._route
183 if "(?P<" in route or route.startswith("^") or route.endswith("$"):
184 warnings.append(
185 Warning(
186 f"Your URL pattern {self.describe()} has a route that contains '(?P<', begins "
187 "with a '^', or ends with a '$'. This was likely an oversight "
188 "when migrating to plain.urls.path().",
189 id="2_0.W001",
190 )
191 )
192 return warnings
193
194 def _compile(self, route):
195 return re.compile(_route_to_regex(route, self._is_endpoint)[0])
196
197 def __str__(self):
198 return str(self._route)
199
200
201class URLPattern:
202 def __init__(self, *, pattern, view, name=None):
203 self.pattern = pattern
204 self.view = view
205 self.name = name
206
207 def __repr__(self):
208 return f"<{self.__class__.__name__} {self.pattern.describe()}>"
209
210 def check(self):
211 warnings = self._check_pattern_name()
212 warnings.extend(self.pattern.check())
213 return warnings
214
215 def _check_pattern_name(self):
216 """
217 Check that the pattern name does not contain a colon.
218 """
219 if self.pattern.name is not None and ":" in self.pattern.name:
220 warning = Warning(
221 f"Your URL pattern {self.pattern.describe()} has a name including a ':'. Remove the colon, to "
222 "avoid ambiguous namespace references.",
223 id="urls.W003",
224 )
225 return [warning]
226 else:
227 return []
228
229 def _check_view(self):
230 from plain.views import View
231
232 view = self.view
233 if inspect.isclass(view) and issubclass(view, View):
234 return [
235 Error(
236 f"Your URL pattern {self.pattern.describe()} has an invalid view, pass {view.__name__}.as_view() "
237 f"instead of {view.__name__}.",
238 id="urls.E009",
239 )
240 ]
241 return []
242
243 def resolve(self, path):
244 match = self.pattern.match(path)
245 if match:
246 new_path, args, captured_kwargs = match
247 from .resolvers import ResolverMatch
248
249 return ResolverMatch(
250 self.view,
251 args,
252 captured_kwargs,
253 self.pattern.name,
254 route=str(self.pattern),
255 captured_kwargs=captured_kwargs,
256 )
257
258 @cached_property
259 def lookup_str(self):
260 """
261 A string that identifies the view (e.g. 'path.to.view_function' or
262 'path.to.ClassBasedView').
263 """
264 view = self.view
265 if isinstance(view, functools.partial):
266 view = view.func
267 if hasattr(view, "view_class"):
268 view = view.view_class
269 elif not hasattr(view, "__name__"):
270 return view.__module__ + "." + view.__class__.__name__
271 return view.__module__ + "." + view.__qualname__