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)