plain 0.21.5__py3-none-any.whl → 0.22.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
plain/urls/resolvers.py CHANGED
@@ -7,25 +7,21 @@ attributes of the resolved URL match.
7
7
  """
8
8
 
9
9
  import functools
10
- import inspect
11
10
  import re
12
- import string
13
11
  from importlib import import_module
14
12
  from pickle import PicklingError
15
13
  from threading import local
16
14
  from urllib.parse import quote
17
15
 
18
- from plain.exceptions import ImproperlyConfigured
19
- from plain.preflight import Error, Warning
20
16
  from plain.preflight.urls import check_resolver
21
17
  from plain.runtime import settings
22
18
  from plain.utils.datastructures import MultiValueDict
23
19
  from plain.utils.functional import cached_property
24
20
  from plain.utils.http import RFC3986_SUBDELIMS, escape_leading_slashes
25
- from plain.utils.regex_helper import _lazy_re_compile, normalize
21
+ from plain.utils.regex_helper import normalize
26
22
 
27
- from .converters import get_converter
28
23
  from .exceptions import NoReverseMatch, Resolver404
24
+ from .patterns import RegexPattern, URLPattern
29
25
 
30
26
 
31
27
  class ResolverMatch:
@@ -35,7 +31,6 @@ class ResolverMatch:
35
31
  args,
36
32
  kwargs,
37
33
  url_name=None,
38
- default_namespaces=None,
39
34
  namespaces=None,
40
35
  route=None,
41
36
  tried=None,
@@ -51,12 +46,8 @@ class ResolverMatch:
51
46
  self.captured_kwargs = captured_kwargs
52
47
  self.extra_kwargs = extra_kwargs
53
48
 
54
- # If a URLRegexResolver doesn't have a namespace or default_namespace, it passes
49
+ # If a URLRegexResolver doesn't have a namespace or namespace, it passes
55
50
  # 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
51
  self.namespaces = [x for x in namespaces if x] if namespaces else []
61
52
  self.namespace = ":".join(self.namespaces)
62
53
 
@@ -72,9 +63,6 @@ class ResolverMatch:
72
63
  view_path = url_name or self._func_path
73
64
  self.view_name = ":".join(self.namespaces + [view_path])
74
65
 
75
- def __getitem__(self, index):
76
- return (self.func, self.args, self.kwargs)[index]
77
-
78
66
  def __repr__(self):
79
67
  if isinstance(self.func, functools.partial):
80
68
  func = repr(self.func)
@@ -82,12 +70,11 @@ class ResolverMatch:
82
70
  func = self._func_path
83
71
  return (
84
72
  "ResolverMatch(func={}, args={!r}, kwargs={!r}, url_name={!r}, "
85
- "default_namespaces={!r}, namespaces={!r}, route={!r}{}{})".format(
73
+ "namespaces={!r}, route={!r}{}{})".format(
86
74
  func,
87
75
  self.args,
88
76
  self.kwargs,
89
77
  self.url_name,
90
- self.default_namespaces,
91
78
  self.namespaces,
92
79
  self.route,
93
80
  f", captured_kwargs={self.captured_kwargs!r}"
@@ -101,324 +88,68 @@ class ResolverMatch:
101
88
  raise PicklingError(f"Cannot pickle {self.__class__.__qualname__}.")
102
89
 
103
90
 
104
- def get_resolver(urlconf=None):
105
- if urlconf is None:
106
- urlconf = settings.ROOT_URLCONF
107
- return _get_cached_resolver(urlconf)
91
+ def get_resolver(urls_module=None):
92
+ if urls_module is None:
93
+ urls_module = settings.URLS_MODULE
94
+
95
+ return _get_cached_resolver(urls_module)
108
96
 
109
97
 
110
98
  @functools.cache
111
- def _get_cached_resolver(urlconf=None):
112
- return URLResolver(RegexPattern(r"^/"), urlconf)
99
+ def _get_cached_resolver(urls_module):
100
+ from .routers import routers_registry
101
+
102
+ if isinstance(urls_module, str):
103
+ # Need to trigger an import in order for the @register_router
104
+ # decorators to run. So this is a sensible entrypoint to do that,
105
+ # usually just for the root URLS_MODULE but could be for anything.
106
+ urls_module = import_module(urls_module)
107
+
108
+ router = routers_registry.get_module_router(urls_module)
109
+ return URLResolver(pattern=RegexPattern(r"^/"), router_class=router)
113
110
 
114
111
 
115
112
  @functools.cache
116
113
  def get_ns_resolver(ns_pattern, resolver, converters):
117
- # Build a namespaced resolver for the given parent URLconf pattern.
114
+ from .routers import RouterBase
115
+
116
+ # Build a namespaced resolver for the given parent urls_module pattern.
118
117
  # This makes it possible to have captured parameters in the parent
119
- # URLconf pattern.
118
+ # urls_module pattern.
120
119
  pattern = RegexPattern(ns_pattern)
121
120
  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
121
 
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 []
122
+ class _NestedRouter(RouterBase):
123
+ urls = resolver.url_patterns
204
124
 
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])
125
+ ns_resolver = URLResolver(pattern=pattern, router_class=_NestedRouter)
307
126
 
308
- def __str__(self):
309
- return str(self._route)
127
+ class _NamespacedRouter(RouterBase):
128
+ urls = [ns_resolver]
310
129
 
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__
130
+ return URLResolver(
131
+ pattern=RegexPattern(r"^/"),
132
+ router_class=_NamespacedRouter,
133
+ )
386
134
 
387
135
 
388
136
  class URLResolver:
389
137
  def __init__(
390
138
  self,
139
+ *,
391
140
  pattern,
392
- urlconf_name,
393
- default_kwargs=None,
394
- default_namespace=None,
395
- namespace=None,
141
+ router_class,
396
142
  ):
397
143
  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
144
+ self.router_class = router_class
406
145
  self._reverse_dict = {}
407
146
  self._namespace_dict = {}
408
147
  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
148
  self._populated = False
413
149
  self._local = local()
414
150
 
415
151
  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()}>"
152
+ return f"<{self.__class__.__name__} {repr(self.router_class)} ({self.namespace}) {self.pattern.describe()}>"
422
153
 
423
154
  def check(self):
424
155
  messages = []
@@ -442,14 +173,12 @@ class URLResolver:
442
173
  p_pattern = url_pattern.pattern.regex.pattern
443
174
  p_pattern = p_pattern.removeprefix("^")
444
175
  if isinstance(url_pattern, URLPattern):
445
- self._callback_strs.add(url_pattern.lookup_str)
446
176
  bits = normalize(url_pattern.pattern.regex.pattern)
447
177
  lookups.appendlist(
448
- url_pattern.callback,
178
+ url_pattern.view,
449
179
  (
450
180
  bits,
451
181
  p_pattern,
452
- url_pattern.default_args,
453
182
  url_pattern.pattern.converters,
454
183
  ),
455
184
  )
@@ -459,14 +188,13 @@ class URLResolver:
459
188
  (
460
189
  bits,
461
190
  p_pattern,
462
- url_pattern.default_args,
463
191
  url_pattern.pattern.converters,
464
192
  ),
465
193
  )
466
194
  else: # url_pattern is a URLResolver.
467
195
  url_pattern._populate()
468
- if url_pattern.default_namespace:
469
- packages.setdefault(url_pattern.default_namespace, []).append(
196
+ if url_pattern.namespace:
197
+ packages.setdefault(url_pattern.namespace, []).append(
470
198
  url_pattern.namespace
471
199
  )
472
200
  namespaces[url_pattern.namespace] = (p_pattern, url_pattern)
@@ -475,7 +203,6 @@ class URLResolver:
475
203
  for (
476
204
  matches,
477
205
  pat,
478
- defaults,
479
206
  converters,
480
207
  ) in url_pattern.reverse_dict.getlist(name):
481
208
  new_matches = normalize(p_pattern + pat)
@@ -484,7 +211,6 @@ class URLResolver:
484
211
  (
485
212
  new_matches,
486
213
  p_pattern + pat,
487
- {**defaults, **url_pattern.default_kwargs},
488
214
  {
489
215
  **self.pattern.converters,
490
216
  **url_pattern.pattern.converters,
@@ -500,13 +226,10 @@ class URLResolver:
500
226
  sub_pattern.pattern.converters.update(current_converters)
501
227
  namespaces[namespace] = (p_pattern + prefix, sub_pattern)
502
228
  for (
503
- default_namespace,
229
+ namespace,
504
230
  namespace_list,
505
231
  ) 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)
232
+ packages.setdefault(namespace, []).extend(namespace_list)
510
233
  self._namespace_dict = namespaces
511
234
  self._app_dict = packages
512
235
  self._reverse_dict = lookups
@@ -547,11 +270,6 @@ class URLResolver:
547
270
  route2 = route2.removeprefix("^")
548
271
  return route1 + route2
549
272
 
550
- def _is_callback(self, name):
551
- if not self._populated:
552
- self._populate()
553
- return name in self._callback_strs
554
-
555
273
  def resolve(self, path):
556
274
  path = str(path) # path may be a reverse_lazy object
557
275
  tried = []
@@ -566,9 +284,8 @@ class URLResolver:
566
284
  else:
567
285
  if sub_match:
568
286
  # Merge captured arguments in match with submatch
569
- sub_match_dict = {**kwargs, **self.default_kwargs}
570
287
  # Update the sub_match_dict with the kwargs from the sub_match.
571
- sub_match_dict.update(sub_match.kwargs)
288
+ sub_match_dict = {**kwargs, **sub_match.kwargs}
572
289
  # If there are *any* named groups, ignore all non-named groups.
573
290
  # Otherwise, pass all non-named arguments as positional
574
291
  # arguments.
@@ -586,42 +303,24 @@ class URLResolver:
586
303
  sub_match_args,
587
304
  sub_match_dict,
588
305
  sub_match.url_name,
589
- [self.default_namespace] + sub_match.default_namespaces,
590
306
  [self.namespace] + sub_match.namespaces,
591
307
  self._join_route(current_route, sub_match.route),
592
308
  tried,
593
309
  captured_kwargs=sub_match.captured_kwargs,
594
- extra_kwargs={
595
- **self.default_kwargs,
596
- **sub_match.extra_kwargs,
597
- },
310
+ extra_kwargs=sub_match.extra_kwargs,
598
311
  )
599
312
  tried.append([pattern])
600
313
  raise Resolver404({"tried": tried, "path": new_path})
601
314
  raise Resolver404({"path": path})
602
315
 
603
316
  @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
317
+ def url_patterns(self):
318
+ # Don't need to instantiate the class because they are just class attributes for now.
319
+ return self.router_class.urls
609
320
 
610
321
  @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
322
+ def namespace(self):
323
+ return self.router_class.namespace
625
324
 
626
325
  def reverse(self, lookup_view, *args, **kwargs):
627
326
  if args and kwargs:
@@ -632,23 +331,14 @@ class URLResolver:
632
331
 
633
332
  possibilities = self.reverse_dict.getlist(lookup_view)
634
333
 
635
- for possibility, pattern, defaults, converters in possibilities:
334
+ for possibility, pattern, converters in possibilities:
636
335
  for result, params in possibility:
637
336
  if args:
638
337
  if len(args) != len(params):
639
338
  continue
640
339
  candidate_subs = dict(zip(params, args))
641
340
  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:
341
+ if set(kwargs).symmetric_difference(params):
652
342
  continue
653
343
  candidate_subs = kwargs
654
344
  # Convert the candidate subs to text using Converter.to_url().