Plain is headed towards 1.0! Subscribe for development updates →

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__