Coverage for /Users/davegaeddert/Development/dropseed/plain/plain/plain/urls/resolvers.py: 74%

1"""

2 This module converts requested URLs to callback view functions.

3

4 URLResolver is the main class here. Its resolve() method takes a URL (as

5 a string) and returns a ResolverMatch object which provides access to all

6 attributes of the resolved URL match.

7"""

8 import functools

9 import inspect

10 import re

11 import string

12 from importlib import import_module

13 from pickle import PicklingError

14 from threading import local

15 from urllib.parse import quote

16

17 from plain.exceptions import ImproperlyConfigured

18 from plain.preflight import Error, Warning

19 from plain.preflight.urls import check_resolver

20 from plain.runtime import settings

21 from plain.utils.datastructures import MultiValueDict

22 from plain.utils.functional import cached_property

23 from plain.utils.http import RFC3986_SUBDELIMS, escape_leading_slashes

24 from plain.utils.regex_helper import _lazy_re_compile, normalize

25

26 from .converters import get_converter

27 from .exceptions import NoReverseMatch, Resolver404

28

29

30 class 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

103 def 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

110 def _get_cached_resolver(urlconf=None):

111 return URLResolver(RegexPattern(r"^/"), urlconf)

112

113

114@functools.cache

115 def 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

125 class 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

160 class 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

224 def _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

271 class 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

315 class 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

395 class 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)