Coverage for /Users/davegaeddert/Developer/dropseed/plain/plain/plain/urls/resolvers.py: 75%

392 statements  

« prev     ^ index     » next       coverage.py v7.6.9, created at 2024-12-23 11:16 -0600

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

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)