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)