Plain is headed towards 1.0! Subscribe for development updates →

urls

Route requests to views.

  1"""
  2This module converts requested URLs to callback view functions.
  3
  4URLResolver is the main class here. Its resolve() method takes a URL (as
  5a string) and returns a ResolverMatch object which provides access to all
  6attributes of the resolved URL match.
  7"""
  8
  9import functools
 10import inspect
 11import re
 12import string
 13from importlib import import_module
 14from pickle import PicklingError
 15from threading import local
 16from urllib.parse import quote
 17
 18from plain.exceptions import ImproperlyConfigured
 19from plain.preflight import Error, Warning
 20from plain.preflight.urls import check_resolver
 21from plain.runtime import settings
 22from plain.utils.datastructures import MultiValueDict
 23from plain.utils.functional import cached_property
 24from plain.utils.http import RFC3986_SUBDELIMS, escape_leading_slashes
 25from plain.utils.regex_helper import _lazy_re_compile, normalize
 26
 27from .converters import get_converter
 28from .exceptions import NoReverseMatch, Resolver404
 29
 30
 31class ResolverMatch:
 32    def __init__(
 33        self,
 34        func,
 35        args,
 36        kwargs,
 37        url_name=None,
 38        default_namespaces=None,
 39        namespaces=None,
 40        route=None,
 41        tried=None,
 42        captured_kwargs=None,
 43        extra_kwargs=None,
 44    ):
 45        self.func = func
 46        self.args = args
 47        self.kwargs = kwargs
 48        self.url_name = url_name
 49        self.route = route
 50        self.tried = tried
 51        self.captured_kwargs = captured_kwargs
 52        self.extra_kwargs = extra_kwargs
 53
 54        # If a URLRegexResolver doesn't have a namespace or default_namespace, it passes
 55        # in an empty value.
 56        self.default_namespaces = (
 57            [x for x in default_namespaces if x] if default_namespaces else []
 58        )
 59        self.default_namespace = ":".join(self.default_namespaces)
 60        self.namespaces = [x for x in namespaces if x] if namespaces else []
 61        self.namespace = ":".join(self.namespaces)
 62
 63        if hasattr(func, "view_class"):
 64            func = func.view_class
 65        if not hasattr(func, "__name__"):
 66            # A class-based view
 67            self._func_path = func.__class__.__module__ + "." + func.__class__.__name__
 68        else:
 69            # A function-based view
 70            self._func_path = func.__module__ + "." + func.__name__
 71
 72        view_path = url_name or self._func_path
 73        self.view_name = ":".join(self.namespaces + [view_path])
 74
 75    def __getitem__(self, index):
 76        return (self.func, self.args, self.kwargs)[index]
 77
 78    def __repr__(self):
 79        if isinstance(self.func, functools.partial):
 80            func = repr(self.func)
 81        else:
 82            func = self._func_path
 83        return (
 84            "ResolverMatch(func={}, args={!r}, kwargs={!r}, url_name={!r}, "
 85            "default_namespaces={!r}, namespaces={!r}, route={!r}{}{})".format(
 86                func,
 87                self.args,
 88                self.kwargs,
 89                self.url_name,
 90                self.default_namespaces,
 91                self.namespaces,
 92                self.route,
 93                f", captured_kwargs={self.captured_kwargs!r}"
 94                if self.captured_kwargs
 95                else "",
 96                f", extra_kwargs={self.extra_kwargs!r}" if self.extra_kwargs else "",
 97            )
 98        )
 99
100    def __reduce_ex__(self, protocol):
101        raise PicklingError(f"Cannot pickle {self.__class__.__qualname__}.")
102
103
104def get_resolver(urlconf=None):
105    if urlconf is None:
106        urlconf = settings.ROOT_URLCONF
107    return _get_cached_resolver(urlconf)
108
109
110@functools.cache
111def _get_cached_resolver(urlconf=None):
112    return URLResolver(RegexPattern(r"^/"), urlconf)
113
114
115@functools.cache
116def get_ns_resolver(ns_pattern, resolver, converters):
117    # Build a namespaced resolver for the given parent URLconf pattern.
118    # This makes it possible to have captured parameters in the parent
119    # URLconf pattern.
120    pattern = RegexPattern(ns_pattern)
121    pattern.converters = dict(converters)
122    ns_resolver = URLResolver(pattern, resolver.url_patterns)
123    return URLResolver(RegexPattern(r"^/"), [ns_resolver])
124
125
126class CheckURLMixin:
127    def describe(self):
128        """
129        Format the URL pattern for display in warning messages.
130        """
131        description = f"'{self}'"
132        if self.name:
133            description += f" [name='{self.name}']"
134        return description
135
136    def _check_pattern_startswith_slash(self):
137        """
138        Check that the pattern does not begin with a forward slash.
139        """
140        regex_pattern = self.regex.pattern
141        if not settings.APPEND_SLASH:
142            # Skip check as it can be useful to start a URL pattern with a slash
143            # when APPEND_SLASH=False.
144            return []
145        if regex_pattern.startswith(("/", "^/", "^\\/")) and not regex_pattern.endswith(
146            "/"
147        ):
148            warning = Warning(
149                f"Your URL pattern {self.describe()} has a route beginning with a '/'. Remove this "
150                "slash as it is unnecessary. If this pattern is targeted in an "
151                "include(), ensure the include() pattern has a trailing '/'.",
152                id="urls.W002",
153            )
154            return [warning]
155        else:
156            return []
157
158
159class RegexPattern(CheckURLMixin):
160    def __init__(self, regex, name=None, is_endpoint=False):
161        self._regex = regex
162        self._regex_dict = {}
163        self._is_endpoint = is_endpoint
164        self.name = name
165        self.converters = {}
166        self.regex = self._compile(str(regex))
167
168    def match(self, path):
169        match = (
170            self.regex.fullmatch(path)
171            if self._is_endpoint and self.regex.pattern.endswith("$")
172            else self.regex.search(path)
173        )
174        if match:
175            # If there are any named groups, use those as kwargs, ignoring
176            # non-named groups. Otherwise, pass all non-named arguments as
177            # positional arguments.
178            kwargs = match.groupdict()
179            args = () if kwargs else match.groups()
180            kwargs = {k: v for k, v in kwargs.items() if v is not None}
181            return path[match.end() :], args, kwargs
182        return None
183
184    def check(self):
185        warnings = []
186        warnings.extend(self._check_pattern_startswith_slash())
187        if not self._is_endpoint:
188            warnings.extend(self._check_include_trailing_dollar())
189        return warnings
190
191    def _check_include_trailing_dollar(self):
192        regex_pattern = self.regex.pattern
193        if regex_pattern.endswith("$") and not regex_pattern.endswith(r"\$"):
194            return [
195                Warning(
196                    f"Your URL pattern {self.describe()} uses include with a route ending with a '$'. "
197                    "Remove the dollar from the route to avoid problems including "
198                    "URLs.",
199                    id="urls.W001",
200                )
201            ]
202        else:
203            return []
204
205    def _compile(self, regex):
206        """Compile and return the given regular expression."""
207        try:
208            return re.compile(regex)
209        except re.error as e:
210            raise ImproperlyConfigured(
211                f'"{regex}" is not a valid regular expression: {e}'
212            ) from e
213
214    def __str__(self):
215        return str(self._regex)
216
217
218_PATH_PARAMETER_COMPONENT_RE = _lazy_re_compile(
219    r"<(?:(?P<converter>[^>:]+):)?(?P<parameter>[^>]+)>"
220)
221
222
223def _route_to_regex(route, is_endpoint=False):
224    """
225    Convert a path pattern into a regular expression. Return the regular
226    expression and a dictionary mapping the capture names to the converters.
227    For example, 'foo/<int:pk>' returns '^foo\\/(?P<pk>[0-9]+)'
228    and {'pk': <plain.urls.converters.IntConverter>}.
229    """
230    original_route = route
231    parts = ["^"]
232    converters = {}
233    while True:
234        match = _PATH_PARAMETER_COMPONENT_RE.search(route)
235        if not match:
236            parts.append(re.escape(route))
237            break
238        elif not set(match.group()).isdisjoint(string.whitespace):
239            raise ImproperlyConfigured(
240                f"URL route '{original_route}' cannot contain whitespace in angle brackets "
241                "<…>."
242            )
243        parts.append(re.escape(route[: match.start()]))
244        route = route[match.end() :]
245        parameter = match["parameter"]
246        if not parameter.isidentifier():
247            raise ImproperlyConfigured(
248                f"URL route '{original_route}' uses parameter name {parameter!r} which isn't a valid "
249                "Python identifier."
250            )
251        raw_converter = match["converter"]
252        if raw_converter is None:
253            # If a converter isn't specified, the default is `str`.
254            raw_converter = "str"
255        try:
256            converter = get_converter(raw_converter)
257        except KeyError as e:
258            raise ImproperlyConfigured(
259                f"URL route {original_route!r} uses invalid converter {raw_converter!r}."
260            ) from e
261        converters[parameter] = converter
262        parts.append("(?P<" + parameter + ">" + converter.regex + ")")
263    if is_endpoint:
264        parts.append(r"\Z")
265    return "".join(parts), converters
266
267
268class RoutePattern(CheckURLMixin):
269    def __init__(self, route, name=None, is_endpoint=False):
270        self._route = route
271        self._regex_dict = {}
272        self._is_endpoint = is_endpoint
273        self.name = name
274        self.converters = _route_to_regex(str(route), is_endpoint)[1]
275        self.regex = self._compile(str(route))
276
277    def match(self, path):
278        match = self.regex.search(path)
279        if match:
280            # RoutePattern doesn't allow non-named groups so args are ignored.
281            kwargs = match.groupdict()
282            for key, value in kwargs.items():
283                converter = self.converters[key]
284                try:
285                    kwargs[key] = converter.to_python(value)
286                except ValueError:
287                    return None
288            return path[match.end() :], (), kwargs
289        return None
290
291    def check(self):
292        warnings = self._check_pattern_startswith_slash()
293        route = self._route
294        if "(?P<" in route or route.startswith("^") or route.endswith("$"):
295            warnings.append(
296                Warning(
297                    f"Your URL pattern {self.describe()} has a route that contains '(?P<', begins "
298                    "with a '^', or ends with a '$'. This was likely an oversight "
299                    "when migrating to plain.urls.path().",
300                    id="2_0.W001",
301                )
302            )
303        return warnings
304
305    def _compile(self, route):
306        return re.compile(_route_to_regex(route, self._is_endpoint)[0])
307
308    def __str__(self):
309        return str(self._route)
310
311
312class URLPattern:
313    def __init__(self, pattern, callback, default_args=None, name=None):
314        self.pattern = pattern
315        self.callback = callback  # the view
316        self.default_args = default_args or {}
317        self.name = name
318
319    def __repr__(self):
320        return f"<{self.__class__.__name__} {self.pattern.describe()}>"
321
322    def check(self):
323        warnings = self._check_pattern_name()
324        warnings.extend(self.pattern.check())
325        warnings.extend(self._check_callback())
326        return warnings
327
328    def _check_pattern_name(self):
329        """
330        Check that the pattern name does not contain a colon.
331        """
332        if self.pattern.name is not None and ":" in self.pattern.name:
333            warning = Warning(
334                f"Your URL pattern {self.pattern.describe()} has a name including a ':'. Remove the colon, to "
335                "avoid ambiguous namespace references.",
336                id="urls.W003",
337            )
338            return [warning]
339        else:
340            return []
341
342    def _check_callback(self):
343        from plain.views import View
344
345        view = self.callback
346        if inspect.isclass(view) and issubclass(view, View):
347            return [
348                Error(
349                    f"Your URL pattern {self.pattern.describe()} has an invalid view, pass {view.__name__}.as_view() "
350                    f"instead of {view.__name__}.",
351                    id="urls.E009",
352                )
353            ]
354        return []
355
356    def resolve(self, path):
357        match = self.pattern.match(path)
358        if match:
359            new_path, args, captured_kwargs = match
360            # Pass any default args as **kwargs.
361            kwargs = {**captured_kwargs, **self.default_args}
362            return ResolverMatch(
363                self.callback,
364                args,
365                kwargs,
366                self.pattern.name,
367                route=str(self.pattern),
368                captured_kwargs=captured_kwargs,
369                extra_kwargs=self.default_args,
370            )
371
372    @cached_property
373    def lookup_str(self):
374        """
375        A string that identifies the view (e.g. 'path.to.view_function' or
376        'path.to.ClassBasedView').
377        """
378        callback = self.callback
379        if isinstance(callback, functools.partial):
380            callback = callback.func
381        if hasattr(callback, "view_class"):
382            callback = callback.view_class
383        elif not hasattr(callback, "__name__"):
384            return callback.__module__ + "." + callback.__class__.__name__
385        return callback.__module__ + "." + callback.__qualname__
386
387
388class URLResolver:
389    def __init__(
390        self,
391        pattern,
392        urlconf_name,
393        default_kwargs=None,
394        default_namespace=None,
395        namespace=None,
396    ):
397        self.pattern = pattern
398        # urlconf_name is the dotted Python path to the module defining
399        # urlpatterns. It may also be an object with an urlpatterns attribute
400        # or urlpatterns itself.
401        self.urlconf_name = urlconf_name
402        self.callback = None
403        self.default_kwargs = default_kwargs or {}
404        self.namespace = namespace
405        self.default_namespace = default_namespace
406        self._reverse_dict = {}
407        self._namespace_dict = {}
408        self._app_dict = {}
409        # set of dotted paths to all functions and classes that are used in
410        # urlpatterns
411        self._callback_strs = set()
412        self._populated = False
413        self._local = local()
414
415    def __repr__(self):
416        if isinstance(self.urlconf_name, list) and self.urlconf_name:
417            # Don't bother to output the whole list, it can be huge
418            urlconf_repr = f"<{self.urlconf_name[0].__class__.__name__} list>"
419        else:
420            urlconf_repr = repr(self.urlconf_name)
421        return f"<{self.__class__.__name__} {urlconf_repr} ({self.default_namespace}:{self.namespace}) {self.pattern.describe()}>"
422
423    def check(self):
424        messages = []
425        for pattern in self.url_patterns:
426            messages.extend(check_resolver(pattern))
427        return messages or self.pattern.check()
428
429    def _populate(self):
430        # Short-circuit if called recursively in this thread to prevent
431        # infinite recursion. Concurrent threads may call this at the same
432        # time and will need to continue, so set 'populating' on a
433        # thread-local variable.
434        if getattr(self._local, "populating", False):
435            return
436        try:
437            self._local.populating = True
438            lookups = MultiValueDict()
439            namespaces = {}
440            packages = {}
441            for url_pattern in reversed(self.url_patterns):
442                p_pattern = url_pattern.pattern.regex.pattern
443                p_pattern = p_pattern.removeprefix("^")
444                if isinstance(url_pattern, URLPattern):
445                    self._callback_strs.add(url_pattern.lookup_str)
446                    bits = normalize(url_pattern.pattern.regex.pattern)
447                    lookups.appendlist(
448                        url_pattern.callback,
449                        (
450                            bits,
451                            p_pattern,
452                            url_pattern.default_args,
453                            url_pattern.pattern.converters,
454                        ),
455                    )
456                    if url_pattern.name is not None:
457                        lookups.appendlist(
458                            url_pattern.name,
459                            (
460                                bits,
461                                p_pattern,
462                                url_pattern.default_args,
463                                url_pattern.pattern.converters,
464                            ),
465                        )
466                else:  # url_pattern is a URLResolver.
467                    url_pattern._populate()
468                    if url_pattern.default_namespace:
469                        packages.setdefault(url_pattern.default_namespace, []).append(
470                            url_pattern.namespace
471                        )
472                        namespaces[url_pattern.namespace] = (p_pattern, url_pattern)
473                    else:
474                        for name in url_pattern.reverse_dict:
475                            for (
476                                matches,
477                                pat,
478                                defaults,
479                                converters,
480                            ) in url_pattern.reverse_dict.getlist(name):
481                                new_matches = normalize(p_pattern + pat)
482                                lookups.appendlist(
483                                    name,
484                                    (
485                                        new_matches,
486                                        p_pattern + pat,
487                                        {**defaults, **url_pattern.default_kwargs},
488                                        {
489                                            **self.pattern.converters,
490                                            **url_pattern.pattern.converters,
491                                            **converters,
492                                        },
493                                    ),
494                                )
495                        for namespace, (
496                            prefix,
497                            sub_pattern,
498                        ) in url_pattern.namespace_dict.items():
499                            current_converters = url_pattern.pattern.converters
500                            sub_pattern.pattern.converters.update(current_converters)
501                            namespaces[namespace] = (p_pattern + prefix, sub_pattern)
502                        for (
503                            default_namespace,
504                            namespace_list,
505                        ) in url_pattern.app_dict.items():
506                            packages.setdefault(default_namespace, []).extend(
507                                namespace_list
508                            )
509                    self._callback_strs.update(url_pattern._callback_strs)
510            self._namespace_dict = namespaces
511            self._app_dict = packages
512            self._reverse_dict = lookups
513            self._populated = True
514        finally:
515            self._local.populating = False
516
517    @property
518    def reverse_dict(self):
519        if not self._reverse_dict:
520            self._populate()
521        return self._reverse_dict
522
523    @property
524    def namespace_dict(self):
525        if not self._namespace_dict:
526            self._populate()
527        return self._namespace_dict
528
529    @property
530    def app_dict(self):
531        if not self._app_dict:
532            self._populate()
533        return self._app_dict
534
535    @staticmethod
536    def _extend_tried(tried, pattern, sub_tried=None):
537        if sub_tried is None:
538            tried.append([pattern])
539        else:
540            tried.extend([pattern, *t] for t in sub_tried)
541
542    @staticmethod
543    def _join_route(route1, route2):
544        """Join two routes, without the starting ^ in the second route."""
545        if not route1:
546            return route2
547        route2 = route2.removeprefix("^")
548        return route1 + route2
549
550    def _is_callback(self, name):
551        if not self._populated:
552            self._populate()
553        return name in self._callback_strs
554
555    def resolve(self, path):
556        path = str(path)  # path may be a reverse_lazy object
557        tried = []
558        match = self.pattern.match(path)
559        if match:
560            new_path, args, kwargs = match
561            for pattern in self.url_patterns:
562                try:
563                    sub_match = pattern.resolve(new_path)
564                except Resolver404 as e:
565                    self._extend_tried(tried, pattern, e.args[0].get("tried"))
566                else:
567                    if sub_match:
568                        # Merge captured arguments in match with submatch
569                        sub_match_dict = {**kwargs, **self.default_kwargs}
570                        # Update the sub_match_dict with the kwargs from the sub_match.
571                        sub_match_dict.update(sub_match.kwargs)
572                        # If there are *any* named groups, ignore all non-named groups.
573                        # Otherwise, pass all non-named arguments as positional
574                        # arguments.
575                        sub_match_args = sub_match.args
576                        if not sub_match_dict:
577                            sub_match_args = args + sub_match.args
578                        current_route = (
579                            ""
580                            if isinstance(pattern, URLPattern)
581                            else str(pattern.pattern)
582                        )
583                        self._extend_tried(tried, pattern, sub_match.tried)
584                        return ResolverMatch(
585                            sub_match.func,
586                            sub_match_args,
587                            sub_match_dict,
588                            sub_match.url_name,
589                            [self.default_namespace] + sub_match.default_namespaces,
590                            [self.namespace] + sub_match.namespaces,
591                            self._join_route(current_route, sub_match.route),
592                            tried,
593                            captured_kwargs=sub_match.captured_kwargs,
594                            extra_kwargs={
595                                **self.default_kwargs,
596                                **sub_match.extra_kwargs,
597                            },
598                        )
599                    tried.append([pattern])
600            raise Resolver404({"tried": tried, "path": new_path})
601        raise Resolver404({"path": path})
602
603    @cached_property
604    def urlconf_module(self):
605        if isinstance(self.urlconf_name, str):
606            return import_module(self.urlconf_name)
607        else:
608            return self.urlconf_name
609
610    @cached_property
611    def url_patterns(self):
612        # urlconf_module might be a valid set of patterns, so we default to it
613        patterns = getattr(self.urlconf_module, "urlpatterns", self.urlconf_module)
614        try:
615            iter(patterns)
616        except TypeError as e:
617            msg = (
618                "The included URLconf '{name}' does not appear to have "
619                "any patterns in it. If you see the 'urlpatterns' variable "
620                "with valid patterns in the file then the issue is probably "
621                "caused by a circular import."
622            )
623            raise ImproperlyConfigured(msg.format(name=self.urlconf_name)) from e
624        return patterns
625
626    def reverse(self, lookup_view, *args, **kwargs):
627        if args and kwargs:
628            raise ValueError("Don't mix *args and **kwargs in call to reverse()!")
629
630        if not self._populated:
631            self._populate()
632
633        possibilities = self.reverse_dict.getlist(lookup_view)
634
635        for possibility, pattern, defaults, converters in possibilities:
636            for result, params in possibility:
637                if args:
638                    if len(args) != len(params):
639                        continue
640                    candidate_subs = dict(zip(params, args))
641                else:
642                    if set(kwargs).symmetric_difference(params).difference(defaults):
643                        continue
644                    matches = True
645                    for k, v in defaults.items():
646                        if k in params:
647                            continue
648                        if kwargs.get(k, v) != v:
649                            matches = False
650                            break
651                    if not matches:
652                        continue
653                    candidate_subs = kwargs
654                # Convert the candidate subs to text using Converter.to_url().
655                text_candidate_subs = {}
656                match = True
657                for k, v in candidate_subs.items():
658                    if k in converters:
659                        try:
660                            text_candidate_subs[k] = converters[k].to_url(v)
661                        except ValueError:
662                            match = False
663                            break
664                    else:
665                        text_candidate_subs[k] = str(v)
666                if not match:
667                    continue
668                # WSGI provides decoded URLs, without %xx escapes, and the URL
669                # resolver operates on such URLs. First substitute arguments
670                # without quoting to build a decoded URL and look for a match.
671                # Then, if we have a match, redo the substitution with quoted
672                # arguments in order to return a properly encoded URL.
673
674                # There was a lot of script_prefix handling code before,
675                # so this is a crutch to leave the below as-is for now.
676                _prefix = "/"
677
678                candidate_pat = _prefix.replace("%", "%%") + result
679                if re.search(
680                    f"^{re.escape(_prefix)}{pattern}",
681                    candidate_pat % text_candidate_subs,
682                ):
683                    # safe characters from `pchar` definition of RFC 3986
684                    url = quote(
685                        candidate_pat % text_candidate_subs,
686                        safe=RFC3986_SUBDELIMS + "/~:@",
687                    )
688                    # Don't allow construction of scheme relative urls.
689                    return escape_leading_slashes(url)
690        # lookup_view can be URL name or callable, but callables are not
691        # friendly in error messages.
692        m = getattr(lookup_view, "__module__", None)
693        n = getattr(lookup_view, "__name__", None)
694        if m is not None and n is not None:
695            lookup_view_s = f"{m}.{n}"
696        else:
697            lookup_view_s = lookup_view
698
699        patterns = [pattern for (_, pattern, _, _) in possibilities]
700        if patterns:
701            if args:
702                arg_msg = f"arguments '{args}'"
703            elif kwargs:
704                arg_msg = f"keyword arguments '{kwargs}'"
705            else:
706                arg_msg = "no arguments"
707            msg = "Reverse for '%s' with %s not found. %d pattern(s) tried: %s" % (  # noqa: UP031
708                lookup_view_s,
709                arg_msg,
710                len(patterns),
711                patterns,
712            )
713        else:
714            msg = (
715                f"Reverse for '{lookup_view_s}' not found. '{lookup_view_s}' is not "
716                "a valid view function or pattern name."
717            )
718        raise NoReverseMatch(msg)