plain 0.21.4__py3-none-any.whl → 0.22.0__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.
Files changed (189) hide show
  1. plain/.pytype/.gitignore +2 -0
  2. plain/.pytype/.ninja_log +17 -0
  3. plain/.pytype/build.ninja +318 -0
  4. plain/.pytype/imports/..packages.__init__.imports +8 -0
  5. plain/.pytype/imports/..packages.__init__.imports-1 +4 -0
  6. plain/.pytype/imports/__main__.imports +0 -0
  7. plain/.pytype/imports/assets.__init__.imports +0 -0
  8. plain/.pytype/imports/assets.compile.imports +10 -0
  9. plain/.pytype/imports/assets.finders.imports +7 -0
  10. plain/.pytype/imports/assets.fingerprints.imports +7 -0
  11. plain/.pytype/imports/assets.urls.imports +51 -0
  12. plain/.pytype/imports/assets.urls.imports-1 +35 -0
  13. plain/.pytype/imports/assets.views.imports +51 -0
  14. plain/.pytype/imports/assets.views.imports-1 +35 -0
  15. plain/.pytype/imports/cli.__init__.imports +16 -0
  16. plain/.pytype/imports/cli.cli.imports +15 -0
  17. plain/.pytype/imports/cli.formatting.imports +2 -0
  18. plain/.pytype/imports/cli.packages.imports +8 -0
  19. plain/.pytype/imports/cli.print.imports +1 -0
  20. plain/.pytype/imports/cli.startup.imports +7 -0
  21. plain/.pytype/imports/debug.imports +2 -0
  22. plain/.pytype/imports/default.pyi +3 -0
  23. plain/.pytype/imports/forms.__init__.imports +23 -0
  24. plain/.pytype/imports/forms.boundfield.imports +0 -0
  25. plain/.pytype/imports/forms.exceptions.imports +1 -0
  26. plain/.pytype/imports/forms.fields.imports +13 -0
  27. plain/.pytype/imports/forms.forms.imports +15 -0
  28. plain/.pytype/imports/http.__init__.imports +20 -0
  29. plain/.pytype/imports/http.__init__.imports-1 +17 -0
  30. plain/.pytype/imports/http.cookie.imports +0 -0
  31. plain/.pytype/imports/http.multipartparser.imports +14 -0
  32. plain/.pytype/imports/http.multipartparser.imports-1 +17 -0
  33. plain/.pytype/imports/http.request.imports +14 -0
  34. plain/.pytype/imports/http.request.imports-1 +25 -0
  35. plain/.pytype/imports/http.response.imports +16 -0
  36. plain/.pytype/imports/internal.__init__.imports +0 -0
  37. plain/.pytype/imports/internal.files.__init__.imports +1 -0
  38. plain/.pytype/imports/internal.files.base.imports +2 -0
  39. plain/.pytype/imports/internal.files.locks.imports +0 -0
  40. plain/.pytype/imports/internal.files.move.imports +1 -0
  41. plain/.pytype/imports/internal.files.temp.imports +1 -0
  42. plain/.pytype/imports/internal.files.uploadedfile.imports +10 -0
  43. plain/.pytype/imports/internal.files.uploadhandler.imports +12 -0
  44. plain/.pytype/imports/internal.files.utils.imports +1 -0
  45. plain/.pytype/imports/internal.handlers.__init__.imports +0 -0
  46. plain/.pytype/imports/internal.handlers.base.imports +53 -0
  47. plain/.pytype/imports/internal.handlers.exception.imports +10 -0
  48. plain/.pytype/imports/internal.handlers.wsgi.imports +29 -0
  49. plain/.pytype/imports/internal.middleware.__init__.imports +0 -0
  50. plain/.pytype/imports/internal.middleware.headers.imports +7 -0
  51. plain/.pytype/imports/internal.middleware.https.imports +8 -0
  52. plain/.pytype/imports/internal.middleware.slash.imports +51 -0
  53. plain/.pytype/imports/json.imports +12 -0
  54. plain/.pytype/imports/json.imports-1 +6 -0
  55. plain/.pytype/imports/json_encoding.imports +3 -0
  56. plain/.pytype/imports/logs.configure.imports +0 -0
  57. plain/.pytype/imports/logs.loggers.imports +0 -0
  58. plain/.pytype/imports/logs.utils.imports +0 -0
  59. plain/.pytype/imports/middleware.imports +13 -0
  60. plain/.pytype/imports/packages.__init__.imports +8 -0
  61. plain/.pytype/imports/packages.__init__.imports-1 +4 -0
  62. plain/.pytype/imports/packages.config.imports +0 -0
  63. plain/.pytype/imports/packages.registry.imports +7 -0
  64. plain/.pytype/imports/packages.registry.imports-1 +3 -0
  65. plain/.pytype/imports/paginator.imports +2 -0
  66. plain/.pytype/imports/preflight.__init__.imports +72 -0
  67. plain/.pytype/imports/preflight.__init__.imports-1 +53 -0
  68. plain/.pytype/imports/preflight.files.imports +8 -0
  69. plain/.pytype/imports/preflight.files.imports-1 +48 -0
  70. plain/.pytype/imports/preflight.messages.imports +7 -0
  71. plain/.pytype/imports/preflight.registry.imports +2 -0
  72. plain/.pytype/imports/preflight.security.imports +11 -0
  73. plain/.pytype/imports/preflight.urls.imports +51 -0
  74. plain/.pytype/imports/preflight.urls.imports-1 +48 -0
  75. plain/.pytype/imports/runtime.global_settings.imports +7 -0
  76. plain/.pytype/imports/runtime.user_settings.imports +7 -0
  77. plain/.pytype/imports/runtime.user_settings.imports-1 +3 -0
  78. plain/.pytype/imports/signals.dispatch.__init__.imports +10 -0
  79. plain/.pytype/imports/signals.dispatch.dispatcher.imports +8 -0
  80. plain/.pytype/imports/templates.__init__.imports +51 -0
  81. plain/.pytype/imports/templates.__init__.imports-1 +35 -0
  82. plain/.pytype/imports/templates.core.imports +51 -0
  83. plain/.pytype/imports/templates.core.imports-1 +35 -0
  84. plain/.pytype/imports/templates.jinja.__init__.imports +51 -0
  85. plain/.pytype/imports/templates.jinja.__init__.imports-1 +35 -0
  86. plain/.pytype/imports/templates.jinja.environments.imports +51 -0
  87. plain/.pytype/imports/templates.jinja.environments.imports-1 +35 -0
  88. plain/.pytype/imports/templates.jinja.extensions.imports +2 -0
  89. plain/.pytype/imports/templates.jinja.filters.imports +7 -0
  90. plain/.pytype/imports/templates.jinja.globals.imports +51 -0
  91. plain/.pytype/imports/templates.jinja.globals.imports-1 +35 -0
  92. plain/.pytype/imports/test.__init__.imports +0 -0
  93. plain/.pytype/imports/test.client.imports +58 -0
  94. plain/.pytype/imports/urls.base.imports +51 -0
  95. plain/.pytype/imports/urls.base.imports-1 +35 -0
  96. plain/.pytype/imports/urls.conf.imports +51 -0
  97. plain/.pytype/imports/urls.conf.imports-1 +35 -0
  98. plain/.pytype/imports/urls.converters.imports +0 -0
  99. plain/.pytype/imports/urls.exceptions.imports +1 -0
  100. plain/.pytype/imports/urls.resolvers.imports +51 -0
  101. plain/.pytype/imports/urls.resolvers.imports-1 +35 -0
  102. plain/.pytype/imports/utils.__init__.imports +0 -0
  103. plain/.pytype/imports/utils._os.imports +1 -0
  104. plain/.pytype/imports/utils.cache.imports +28 -0
  105. plain/.pytype/imports/utils.connection.imports +8 -0
  106. plain/.pytype/imports/utils.datastructures.imports +0 -0
  107. plain/.pytype/imports/utils.dateformat.imports +4 -0
  108. plain/.pytype/imports/utils.dateparse.imports +2 -0
  109. plain/.pytype/imports/utils.dates.imports +0 -0
  110. plain/.pytype/imports/utils.deconstruct.imports +0 -0
  111. plain/.pytype/imports/utils.decorators.imports +0 -0
  112. plain/.pytype/imports/utils.deprecation.imports +0 -0
  113. plain/.pytype/imports/utils.duration.imports +0 -0
  114. plain/.pytype/imports/utils.email.imports +0 -0
  115. plain/.pytype/imports/utils.encoding.imports +1 -0
  116. plain/.pytype/imports/utils.hashable.imports +1 -0
  117. plain/.pytype/imports/utils.html.imports +10 -0
  118. plain/.pytype/imports/utils.inspect.imports +0 -0
  119. plain/.pytype/imports/utils.ipv6.imports +1 -0
  120. plain/.pytype/imports/utils.itercompat.imports +0 -0
  121. plain/.pytype/imports/utils.module_loading.imports +0 -0
  122. plain/.pytype/imports/utils.regex_helper.imports +1 -0
  123. plain/.pytype/imports/utils.safestring.imports +1 -0
  124. plain/.pytype/imports/utils.text.imports +3 -0
  125. plain/.pytype/imports/utils.timesince.imports +6 -0
  126. plain/.pytype/imports/utils.timezone.imports +8 -0
  127. plain/.pytype/imports/utils.tree.imports +2 -0
  128. plain/.pytype/imports/validators.imports +7 -0
  129. plain/.pytype/imports/views.__init__.imports +72 -0
  130. plain/.pytype/imports/views.__init__.imports-1 +53 -0
  131. plain/.pytype/imports/views.base.imports +3 -0
  132. plain/.pytype/imports/views.csrf.imports +1 -0
  133. plain/.pytype/imports/views.errors.imports +51 -0
  134. plain/.pytype/imports/views.exceptions.imports +0 -0
  135. plain/.pytype/imports/views.forms.imports +51 -0
  136. plain/.pytype/imports/views.forms.imports-1 +35 -0
  137. plain/.pytype/imports/views.imports +51 -0
  138. plain/.pytype/imports/views.objects.imports +51 -0
  139. plain/.pytype/imports/views.objects.imports-1 +35 -0
  140. plain/.pytype/imports/views.redirect.imports +51 -0
  141. plain/.pytype/imports/views.redirect.imports-1 +35 -0
  142. plain/.pytype/imports/views.templates.imports +51 -0
  143. plain/.pytype/imports/views.templates.imports-1 +35 -0
  144. plain/.pytype/imports/wsgi.imports +8 -0
  145. plain/.pytype/pyi/json.pyi +15 -0
  146. plain/.pytype/pyi/json.pyi-1 +15 -0
  147. plain/.pytype/pyi/logs/configure.pyi +8 -0
  148. plain/.pytype/pyi/logs/loggers.pyi +20 -0
  149. plain/.pytype/pyi/logs/utils.pyi +7 -0
  150. plain/.pytype/pyi/packages/__init__.pyi +8 -0
  151. plain/.pytype/pyi/packages/__init__.pyi-1 +8 -0
  152. plain/.pytype/pyi/packages/config.pyi +35 -0
  153. plain/.pytype/pyi/packages/registry.pyi +49 -0
  154. plain/.pytype/pyi/packages/registry.pyi-1 +49 -0
  155. plain/.pytype/pyi/runtime/user_settings.pyi +64 -0
  156. plain/.pytype/pyi/runtime/user_settings.pyi-1 +64 -0
  157. plain/.pytype/pyi/utils/duration.pyi +9 -0
  158. plain/.pytype/pyi/utils/hashable.pyi +9 -0
  159. plain/.pytype/pyi/utils/itercompat.pyi +3 -0
  160. plain/.pytype/pyi/utils/module_loading.pyi +14 -0
  161. plain/assets/urls.py +9 -8
  162. plain/assets/views.py +4 -4
  163. plain/cli/cli.py +90 -7
  164. plain/cli/packages.py +25 -8
  165. plain/http/request.py +1 -2
  166. plain/internal/handlers/base.py +9 -23
  167. plain/internal/handlers/exception.py +2 -0
  168. plain/internal/middleware/slash.py +15 -7
  169. plain/packages/registry.py +3 -77
  170. plain/preflight/urls.py +2 -2
  171. plain/runtime/global_settings.py +1 -1
  172. plain/templates/jinja/globals.py +2 -8
  173. plain/test/client.py +10 -10
  174. plain/urls/__init__.py +8 -19
  175. plain/urls/patterns.py +271 -0
  176. plain/urls/resolvers.py +48 -360
  177. plain/urls/routers.py +90 -0
  178. plain/urls/{base.py → utils.py} +5 -61
  179. plain/utils/functional.py +1 -2
  180. plain/views/redirect.py +1 -1
  181. plain/views/templates.py +1 -1
  182. {plain-0.21.4.dist-info → plain-0.22.0.dist-info}/METADATA +1 -1
  183. plain-0.22.0.dist-info/RECORD +305 -0
  184. plain/urls/conf.py +0 -95
  185. plain/utils/deprecation.py +0 -6
  186. plain-0.21.4.dist-info/RECORD +0 -145
  187. {plain-0.21.4.dist-info → plain-0.22.0.dist-info}/WHEEL +0 -0
  188. {plain-0.21.4.dist-info → plain-0.22.0.dist-info}/entry_points.txt +0 -0
  189. {plain-0.21.4.dist-info → plain-0.22.0.dist-info}/licenses/LICENSE +0 -0
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,70 @@ 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
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.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,
141
+ router_class,
395
142
  namespace=None,
396
143
  ):
397
144
  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 {}
145
+ self.router_class = router_class
404
146
  self.namespace = namespace
405
- self.default_namespace = default_namespace
406
147
  self._reverse_dict = {}
407
148
  self._namespace_dict = {}
408
149
  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
150
  self._populated = False
413
151
  self._local = local()
414
152
 
415
153
  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()}>"
154
+ return f"<{self.__class__.__name__} {repr(self.router_class)} ({self.namespace}) {self.pattern.describe()}>"
422
155
 
423
156
  def check(self):
424
157
  messages = []
@@ -442,14 +175,12 @@ class URLResolver:
442
175
  p_pattern = url_pattern.pattern.regex.pattern
443
176
  p_pattern = p_pattern.removeprefix("^")
444
177
  if isinstance(url_pattern, URLPattern):
445
- self._callback_strs.add(url_pattern.lookup_str)
446
178
  bits = normalize(url_pattern.pattern.regex.pattern)
447
179
  lookups.appendlist(
448
- url_pattern.callback,
180
+ url_pattern.view,
449
181
  (
450
182
  bits,
451
183
  p_pattern,
452
- url_pattern.default_args,
453
184
  url_pattern.pattern.converters,
454
185
  ),
455
186
  )
@@ -459,14 +190,13 @@ class URLResolver:
459
190
  (
460
191
  bits,
461
192
  p_pattern,
462
- url_pattern.default_args,
463
193
  url_pattern.pattern.converters,
464
194
  ),
465
195
  )
466
196
  else: # url_pattern is a URLResolver.
467
197
  url_pattern._populate()
468
- if url_pattern.default_namespace:
469
- packages.setdefault(url_pattern.default_namespace, []).append(
198
+ if url_pattern.namespace:
199
+ packages.setdefault(url_pattern.namespace, []).append(
470
200
  url_pattern.namespace
471
201
  )
472
202
  namespaces[url_pattern.namespace] = (p_pattern, url_pattern)
@@ -475,7 +205,6 @@ class URLResolver:
475
205
  for (
476
206
  matches,
477
207
  pat,
478
- defaults,
479
208
  converters,
480
209
  ) in url_pattern.reverse_dict.getlist(name):
481
210
  new_matches = normalize(p_pattern + pat)
@@ -484,7 +213,6 @@ class URLResolver:
484
213
  (
485
214
  new_matches,
486
215
  p_pattern + pat,
487
- {**defaults, **url_pattern.default_kwargs},
488
216
  {
489
217
  **self.pattern.converters,
490
218
  **url_pattern.pattern.converters,
@@ -500,13 +228,10 @@ class URLResolver:
500
228
  sub_pattern.pattern.converters.update(current_converters)
501
229
  namespaces[namespace] = (p_pattern + prefix, sub_pattern)
502
230
  for (
503
- default_namespace,
231
+ namespace,
504
232
  namespace_list,
505
233
  ) 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)
234
+ packages.setdefault(namespace, []).extend(namespace_list)
510
235
  self._namespace_dict = namespaces
511
236
  self._app_dict = packages
512
237
  self._reverse_dict = lookups
@@ -547,11 +272,6 @@ class URLResolver:
547
272
  route2 = route2.removeprefix("^")
548
273
  return route1 + route2
549
274
 
550
- def _is_callback(self, name):
551
- if not self._populated:
552
- self._populate()
553
- return name in self._callback_strs
554
-
555
275
  def resolve(self, path):
556
276
  path = str(path) # path may be a reverse_lazy object
557
277
  tried = []
@@ -566,9 +286,8 @@ class URLResolver:
566
286
  else:
567
287
  if sub_match:
568
288
  # Merge captured arguments in match with submatch
569
- sub_match_dict = {**kwargs, **self.default_kwargs}
570
289
  # Update the sub_match_dict with the kwargs from the sub_match.
571
- sub_match_dict.update(sub_match.kwargs)
290
+ sub_match_dict = {**kwargs, **sub_match.kwargs}
572
291
  # If there are *any* named groups, ignore all non-named groups.
573
292
  # Otherwise, pass all non-named arguments as positional
574
293
  # arguments.
@@ -586,42 +305,20 @@ class URLResolver:
586
305
  sub_match_args,
587
306
  sub_match_dict,
588
307
  sub_match.url_name,
589
- [self.default_namespace] + sub_match.default_namespaces,
590
308
  [self.namespace] + sub_match.namespaces,
591
309
  self._join_route(current_route, sub_match.route),
592
310
  tried,
593
311
  captured_kwargs=sub_match.captured_kwargs,
594
- extra_kwargs={
595
- **self.default_kwargs,
596
- **sub_match.extra_kwargs,
597
- },
312
+ extra_kwargs=sub_match.extra_kwargs,
598
313
  )
599
314
  tried.append([pattern])
600
315
  raise Resolver404({"tried": tried, "path": new_path})
601
316
  raise Resolver404({"path": path})
602
317
 
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
318
  @cached_property
611
319
  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
320
+ # Don't need to instantiate the class because they are just class attributes for now.
321
+ return self.router_class.urls
625
322
 
626
323
  def reverse(self, lookup_view, *args, **kwargs):
627
324
  if args and kwargs:
@@ -632,23 +329,14 @@ class URLResolver:
632
329
 
633
330
  possibilities = self.reverse_dict.getlist(lookup_view)
634
331
 
635
- for possibility, pattern, defaults, converters in possibilities:
332
+ for possibility, pattern, converters in possibilities:
636
333
  for result, params in possibility:
637
334
  if args:
638
335
  if len(args) != len(params):
639
336
  continue
640
337
  candidate_subs = dict(zip(params, args))
641
338
  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:
339
+ if set(kwargs).symmetric_difference(params):
652
340
  continue
653
341
  candidate_subs = kwargs
654
342
  # Convert the candidate subs to text using Converter.to_url().
plain/urls/routers.py ADDED
@@ -0,0 +1,90 @@
1
+ from abc import ABC
2
+
3
+ from plain.exceptions import ImproperlyConfigured
4
+
5
+ from .patterns import RoutePattern, URLPattern
6
+ from .resolvers import (
7
+ URLResolver,
8
+ )
9
+
10
+
11
+ class RouterBase(ABC):
12
+ namespace: str = ""
13
+ urls: list # Required
14
+
15
+
16
+ class RoutersRegistry:
17
+ """Keep track of all the Routers that are explicitly registered in packages."""
18
+
19
+ def __init__(self):
20
+ self._routers = {}
21
+
22
+ def register_router(self, router_class):
23
+ router_module_name = router_class.__module__
24
+ self._routers[router_module_name] = router_class
25
+ return router_class
26
+
27
+ def get_module_router(self, module):
28
+ if isinstance(module, str):
29
+ module_name = module
30
+ else:
31
+ module_name = module.__name__
32
+
33
+ try:
34
+ return self._routers[module_name]
35
+ except KeyError as e:
36
+ registered_routers = ", ".join(self._routers.keys()) or "None"
37
+ raise ImproperlyConfigured(
38
+ f"Router {module_name} is not registered with the resolver. Use @register_router on the Router class in urls.py.\n\nRegistered routers: {registered_routers}"
39
+ ) from e
40
+
41
+
42
+ def include(route, module_or_urls, *, Pattern=RoutePattern):
43
+ pattern = Pattern(route, is_endpoint=False)
44
+
45
+ if isinstance(module_or_urls, list | tuple):
46
+ # We were given an explicit list of sub-patterns,
47
+ # so we generate a router for it
48
+ class _IncludeRouter(RouterBase):
49
+ urls = module_or_urls
50
+
51
+ return URLResolver(pattern=pattern, router_class=_IncludeRouter, namespace=None)
52
+ else:
53
+ # We were given a module, so we need to look up the router for that module
54
+ module = module_or_urls
55
+ router = routers.get_module_router(module)
56
+ namespace = router.namespace
57
+
58
+ return URLResolver(
59
+ pattern=pattern,
60
+ router_class=router,
61
+ namespace=namespace,
62
+ )
63
+
64
+
65
+ def path(route, view, *, name=None, Pattern=RoutePattern):
66
+ from plain.views import View
67
+
68
+ pattern = Pattern(route, name=name, is_endpoint=True)
69
+
70
+ # You can't pass a View() instance to path()
71
+ if isinstance(view, View):
72
+ view_cls_name = view.__class__.__name__
73
+ raise TypeError(
74
+ f"view must be a callable, pass {view_cls_name} or {view_cls_name}.as_view(*args, **kwargs), not "
75
+ f"{view_cls_name}()."
76
+ )
77
+
78
+ # You typically pass a View class and we call as_view() for you
79
+ if issubclass(view, View):
80
+ return URLPattern(pattern=pattern, view=view.as_view(), name=name)
81
+
82
+ # If you called View.as_view() yourself (or technically any callable)
83
+ if callable(view):
84
+ return URLPattern(pattern=pattern, view=view, name=name)
85
+
86
+ raise TypeError("view must be a View class or View.as_view()")
87
+
88
+
89
+ routers = RoutersRegistry()
90
+ register_router = routers.register_router