Coverage for /Users/davegaeddert/Developer/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

9 import functools

10 import inspect

11 import re

12 import string

13 from importlib import import_module

14 from pickle import PicklingError

15 from threading import local

16 from urllib.parse import quote

17

18 from plain.exceptions import ImproperlyConfigured

19 from plain.preflight import Error, Warning

20 from plain.preflight.urls import check_resolver

21 from plain.runtime import settings

22 from plain.utils.datastructures import MultiValueDict

23 from plain.utils.functional import cached_property

24 from plain.utils.http import RFC3986_SUBDELIMS, escape_leading_slashes

25 from plain.utils.regex_helper import _lazy_re_compile, normalize

26

27 from .converters import get_converter

28 from .exceptions import NoReverseMatch, Resolver404

29

30

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

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

111 def _get_cached_resolver(urlconf=None):

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

113

114

115@functools.cache

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

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

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

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

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

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

388 class 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" % (

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)