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

392 statements  

« prev     ^ index     » next       coverage.py v7.6.1, created at 2024-10-16 22:04 -0500

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)