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