plain 0.66.0__py3-none-any.whl → 0.101.2__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 (197) hide show
  1. plain/CHANGELOG.md +684 -0
  2. plain/README.md +1 -1
  3. plain/assets/compile.py +25 -12
  4. plain/assets/finders.py +24 -17
  5. plain/assets/fingerprints.py +10 -7
  6. plain/assets/urls.py +1 -1
  7. plain/assets/views.py +47 -33
  8. plain/chores/README.md +25 -23
  9. plain/chores/__init__.py +2 -1
  10. plain/chores/core.py +27 -0
  11. plain/chores/registry.py +23 -53
  12. plain/cli/README.md +185 -16
  13. plain/cli/__init__.py +2 -1
  14. plain/cli/agent.py +236 -0
  15. plain/cli/build.py +7 -8
  16. plain/cli/changelog.py +11 -5
  17. plain/cli/chores.py +32 -34
  18. plain/cli/core.py +112 -28
  19. plain/cli/docs.py +52 -11
  20. plain/cli/formatting.py +40 -17
  21. plain/cli/install.py +10 -54
  22. plain/cli/{agent/llmdocs.py → llmdocs.py} +21 -9
  23. plain/cli/output.py +6 -2
  24. plain/cli/preflight.py +175 -102
  25. plain/cli/print.py +4 -4
  26. plain/cli/registry.py +95 -26
  27. plain/cli/request.py +206 -0
  28. plain/cli/runtime.py +45 -0
  29. plain/cli/scaffold.py +2 -7
  30. plain/cli/server.py +153 -0
  31. plain/cli/settings.py +53 -49
  32. plain/cli/shell.py +15 -12
  33. plain/cli/startup.py +9 -8
  34. plain/cli/upgrade.py +17 -104
  35. plain/cli/urls.py +12 -7
  36. plain/cli/utils.py +3 -3
  37. plain/csrf/README.md +65 -40
  38. plain/csrf/middleware.py +53 -43
  39. plain/debug.py +5 -2
  40. plain/exceptions.py +22 -114
  41. plain/forms/README.md +453 -24
  42. plain/forms/__init__.py +55 -4
  43. plain/forms/boundfield.py +15 -8
  44. plain/forms/exceptions.py +1 -1
  45. plain/forms/fields.py +346 -143
  46. plain/forms/forms.py +75 -45
  47. plain/http/README.md +356 -9
  48. plain/http/__init__.py +41 -26
  49. plain/http/cookie.py +15 -7
  50. plain/http/exceptions.py +65 -0
  51. plain/http/middleware.py +32 -0
  52. plain/http/multipartparser.py +99 -88
  53. plain/http/request.py +362 -250
  54. plain/http/response.py +99 -197
  55. plain/internal/__init__.py +8 -1
  56. plain/internal/files/base.py +35 -19
  57. plain/internal/files/locks.py +19 -11
  58. plain/internal/files/move.py +8 -3
  59. plain/internal/files/temp.py +25 -6
  60. plain/internal/files/uploadedfile.py +47 -28
  61. plain/internal/files/uploadhandler.py +64 -58
  62. plain/internal/files/utils.py +24 -10
  63. plain/internal/handlers/base.py +34 -23
  64. plain/internal/handlers/exception.py +68 -65
  65. plain/internal/handlers/wsgi.py +65 -54
  66. plain/internal/middleware/headers.py +37 -11
  67. plain/internal/middleware/hosts.py +11 -13
  68. plain/internal/middleware/https.py +17 -7
  69. plain/internal/middleware/slash.py +14 -9
  70. plain/internal/reloader.py +77 -0
  71. plain/json.py +2 -1
  72. plain/logs/README.md +161 -62
  73. plain/logs/__init__.py +1 -1
  74. plain/logs/{loggers.py → app.py} +71 -67
  75. plain/logs/configure.py +63 -14
  76. plain/logs/debug.py +17 -6
  77. plain/logs/filters.py +15 -0
  78. plain/logs/formatters.py +7 -4
  79. plain/packages/README.md +105 -23
  80. plain/packages/config.py +15 -7
  81. plain/packages/registry.py +40 -15
  82. plain/paginator.py +31 -21
  83. plain/preflight/README.md +208 -23
  84. plain/preflight/__init__.py +5 -24
  85. plain/preflight/checks.py +12 -0
  86. plain/preflight/files.py +19 -13
  87. plain/preflight/registry.py +80 -58
  88. plain/preflight/results.py +37 -0
  89. plain/preflight/security.py +65 -71
  90. plain/preflight/settings.py +54 -0
  91. plain/preflight/urls.py +10 -48
  92. plain/runtime/README.md +115 -47
  93. plain/runtime/__init__.py +10 -6
  94. plain/runtime/global_settings.py +43 -33
  95. plain/runtime/secret.py +20 -0
  96. plain/runtime/user_settings.py +110 -38
  97. plain/runtime/utils.py +1 -1
  98. plain/server/LICENSE +35 -0
  99. plain/server/README.md +155 -0
  100. plain/server/__init__.py +9 -0
  101. plain/server/app.py +52 -0
  102. plain/server/arbiter.py +555 -0
  103. plain/server/config.py +118 -0
  104. plain/server/errors.py +31 -0
  105. plain/server/glogging.py +292 -0
  106. plain/server/http/__init__.py +12 -0
  107. plain/server/http/body.py +283 -0
  108. plain/server/http/errors.py +155 -0
  109. plain/server/http/message.py +400 -0
  110. plain/server/http/parser.py +70 -0
  111. plain/server/http/unreader.py +88 -0
  112. plain/server/http/wsgi.py +421 -0
  113. plain/server/pidfile.py +92 -0
  114. plain/server/sock.py +240 -0
  115. plain/server/util.py +317 -0
  116. plain/server/workers/__init__.py +6 -0
  117. plain/server/workers/base.py +304 -0
  118. plain/server/workers/sync.py +212 -0
  119. plain/server/workers/thread.py +399 -0
  120. plain/server/workers/workertmp.py +50 -0
  121. plain/signals/README.md +170 -1
  122. plain/signals/__init__.py +0 -1
  123. plain/signals/dispatch/dispatcher.py +49 -27
  124. plain/signing.py +131 -35
  125. plain/skills/README.md +36 -0
  126. plain/skills/plain-docs/SKILL.md +25 -0
  127. plain/skills/plain-install/SKILL.md +26 -0
  128. plain/skills/plain-request/SKILL.md +39 -0
  129. plain/skills/plain-shell/SKILL.md +24 -0
  130. plain/skills/plain-upgrade/SKILL.md +35 -0
  131. plain/templates/README.md +211 -20
  132. plain/templates/jinja/__init__.py +14 -27
  133. plain/templates/jinja/environments.py +5 -4
  134. plain/templates/jinja/extensions.py +12 -5
  135. plain/templates/jinja/filters.py +7 -2
  136. plain/templates/jinja/globals.py +2 -2
  137. plain/test/README.md +184 -22
  138. plain/test/client.py +340 -222
  139. plain/test/encoding.py +9 -6
  140. plain/test/exceptions.py +7 -2
  141. plain/urls/README.md +157 -73
  142. plain/urls/converters.py +18 -15
  143. plain/urls/exceptions.py +2 -2
  144. plain/urls/patterns.py +56 -40
  145. plain/urls/resolvers.py +38 -28
  146. plain/urls/utils.py +5 -1
  147. plain/utils/README.md +250 -3
  148. plain/utils/cache.py +17 -11
  149. plain/utils/crypto.py +21 -5
  150. plain/utils/datastructures.py +89 -56
  151. plain/utils/dateparse.py +9 -6
  152. plain/utils/deconstruct.py +15 -7
  153. plain/utils/decorators.py +5 -1
  154. plain/utils/dotenv.py +373 -0
  155. plain/utils/duration.py +8 -4
  156. plain/utils/encoding.py +14 -7
  157. plain/utils/functional.py +66 -49
  158. plain/utils/hashable.py +5 -1
  159. plain/utils/html.py +36 -22
  160. plain/utils/http.py +16 -9
  161. plain/utils/inspect.py +14 -6
  162. plain/utils/ipv6.py +7 -3
  163. plain/utils/itercompat.py +6 -1
  164. plain/utils/module_loading.py +7 -3
  165. plain/utils/regex_helper.py +37 -23
  166. plain/utils/safestring.py +14 -6
  167. plain/utils/text.py +41 -23
  168. plain/utils/timezone.py +33 -22
  169. plain/utils/tree.py +35 -19
  170. plain/validators.py +94 -52
  171. plain/views/README.md +156 -79
  172. plain/views/__init__.py +0 -1
  173. plain/views/base.py +25 -18
  174. plain/views/errors.py +13 -5
  175. plain/views/exceptions.py +4 -1
  176. plain/views/forms.py +6 -6
  177. plain/views/objects.py +52 -49
  178. plain/views/redirect.py +18 -15
  179. plain/views/templates.py +5 -3
  180. plain/wsgi.py +3 -1
  181. {plain-0.66.0.dist-info → plain-0.101.2.dist-info}/METADATA +4 -2
  182. plain-0.101.2.dist-info/RECORD +201 -0
  183. {plain-0.66.0.dist-info → plain-0.101.2.dist-info}/WHEEL +1 -1
  184. plain-0.101.2.dist-info/entry_points.txt +2 -0
  185. plain/AGENTS.md +0 -18
  186. plain/cli/agent/__init__.py +0 -20
  187. plain/cli/agent/docs.py +0 -80
  188. plain/cli/agent/md.py +0 -87
  189. plain/cli/agent/prompt.py +0 -45
  190. plain/cli/agent/request.py +0 -181
  191. plain/csrf/views.py +0 -31
  192. plain/logs/utils.py +0 -46
  193. plain/preflight/messages.py +0 -81
  194. plain/templates/AGENTS.md +0 -3
  195. plain-0.66.0.dist-info/RECORD +0 -168
  196. plain-0.66.0.dist-info/entry_points.txt +0 -4
  197. {plain-0.66.0.dist-info → plain-0.101.2.dist-info}/licenses/LICENSE +0 -0
plain/urls/patterns.py CHANGED
@@ -1,18 +1,25 @@
1
+ from __future__ import annotations
2
+
1
3
  import re
2
4
  import string
5
+ from typing import Any
3
6
 
4
7
  from plain.exceptions import ImproperlyConfigured
5
8
  from plain.internal import internalcode
6
- from plain.preflight import Warning
9
+ from plain.preflight import PreflightResult
7
10
  from plain.runtime import settings
8
11
  from plain.utils.regex_helper import _lazy_re_compile
9
12
 
10
- from .converters import get_converter
13
+ from .converters import _get_converter
11
14
 
12
15
 
13
16
  @internalcode
14
17
  class CheckURLMixin:
15
- def describe(self):
18
+ # Expected to be set by subclasses
19
+ regex: re.Pattern[str]
20
+ name: str | None
21
+
22
+ def describe(self) -> str:
16
23
  """
17
24
  Format the URL pattern for display in warning messages.
18
25
  """
@@ -21,7 +28,7 @@ class CheckURLMixin:
21
28
  description += f" [name='{self.name}']"
22
29
  return description
23
30
 
24
- def _check_pattern_startswith_slash(self):
31
+ def _check_pattern_startswith_slash(self) -> list[PreflightResult]:
25
32
  """
26
33
  Check that the pattern does not begin with a forward slash.
27
34
  """
@@ -33,11 +40,10 @@ class CheckURLMixin:
33
40
  if regex_pattern.startswith(("/", "^/", "^\\/")) and not regex_pattern.endswith(
34
41
  "/"
35
42
  ):
36
- warning = Warning(
37
- f"Your URL pattern {self.describe()} has a route beginning with a '/'. Remove this "
38
- "slash as it is unnecessary. If this pattern is targeted in an "
39
- "include(), ensure the include() pattern has a trailing '/'.",
40
- id="urls.W002",
43
+ warning = PreflightResult(
44
+ fix=f"URL pattern {self.describe()} starts with unnecessary '/'. Remove the leading slash.",
45
+ warning=True,
46
+ id="urls.pattern_starts_with_slash",
41
47
  )
42
48
  return [warning]
43
49
  else:
@@ -45,14 +51,14 @@ class CheckURLMixin:
45
51
 
46
52
 
47
53
  class RegexPattern(CheckURLMixin):
48
- def __init__(self, regex, name=None, is_endpoint=False):
54
+ def __init__(self, regex: str, name: str | None = None, is_endpoint: bool = False):
49
55
  self._regex = regex
50
56
  self._is_endpoint = is_endpoint
51
57
  self.name = name
52
- self.converters = {}
58
+ self.converters: dict[str, Any] = {}
53
59
  self.regex = self._compile(str(regex))
54
60
 
55
- def match(self, path):
61
+ def match(self, path: str) -> tuple[str, tuple[Any, ...], dict[str, Any]] | None:
56
62
  match = (
57
63
  self.regex.fullmatch(path)
58
64
  if self._is_endpoint and self.regex.pattern.endswith("$")
@@ -68,28 +74,27 @@ class RegexPattern(CheckURLMixin):
68
74
  return path[match.end() :], args, kwargs
69
75
  return None
70
76
 
71
- def check(self):
77
+ def preflight(self) -> list[PreflightResult]:
72
78
  warnings = []
73
79
  warnings.extend(self._check_pattern_startswith_slash())
74
80
  if not self._is_endpoint:
75
81
  warnings.extend(self._check_include_trailing_dollar())
76
82
  return warnings
77
83
 
78
- def _check_include_trailing_dollar(self):
84
+ def _check_include_trailing_dollar(self) -> list[PreflightResult]:
79
85
  regex_pattern = self.regex.pattern
80
86
  if regex_pattern.endswith("$") and not regex_pattern.endswith(r"\$"):
81
87
  return [
82
- Warning(
83
- f"Your URL pattern {self.describe()} uses include with a route ending with a '$'. "
84
- "Remove the dollar from the route to avoid problems including "
85
- "URLs.",
86
- id="urls.W001",
88
+ PreflightResult(
89
+ fix=f"Include pattern {self.describe()} ends with '$' which prevents URL inclusion. Remove the dollar sign.",
90
+ warning=True,
91
+ id="urls.include_pattern_ends_with_dollar",
87
92
  )
88
93
  ]
89
94
  else:
90
95
  return []
91
96
 
92
- def _compile(self, regex):
97
+ def _compile(self, regex: str) -> re.Pattern[str]:
93
98
  """Compile and return the given regular expression."""
94
99
  try:
95
100
  return re.compile(regex)
@@ -98,7 +103,7 @@ class RegexPattern(CheckURLMixin):
98
103
  f'"{regex}" is not a valid regular expression: {e}'
99
104
  ) from e
100
105
 
101
- def __str__(self):
106
+ def __str__(self) -> str:
102
107
  return str(self._regex)
103
108
 
104
109
 
@@ -107,7 +112,9 @@ _PATH_PARAMETER_COMPONENT_RE = _lazy_re_compile(
107
112
  )
108
113
 
109
114
 
110
- def _route_to_regex(route, is_endpoint=False):
115
+ def _route_to_regex(
116
+ route: str, is_endpoint: bool = False
117
+ ) -> tuple[str, dict[str, Any]]:
111
118
  """
112
119
  Convert a path pattern into a regular expression. Return the regular
113
120
  expression and a dictionary mapping the capture names to the converters.
@@ -140,7 +147,7 @@ def _route_to_regex(route, is_endpoint=False):
140
147
  # If a converter isn't specified, the default is `str`.
141
148
  raw_converter = "str"
142
149
  try:
143
- converter = get_converter(raw_converter)
150
+ converter = _get_converter(raw_converter)
144
151
  except KeyError as e:
145
152
  raise ImproperlyConfigured(
146
153
  f"URL route {original_route!r} uses invalid converter {raw_converter!r}."
@@ -153,14 +160,14 @@ def _route_to_regex(route, is_endpoint=False):
153
160
 
154
161
 
155
162
  class RoutePattern(CheckURLMixin):
156
- def __init__(self, route, name=None, is_endpoint=False):
163
+ def __init__(self, route: str, name: str | None = None, is_endpoint: bool = False):
157
164
  self._route = route
158
165
  self._is_endpoint = is_endpoint
159
166
  self.name = name
160
167
  self.converters = _route_to_regex(str(route), is_endpoint)[1]
161
168
  self.regex = self._compile(str(route))
162
169
 
163
- def match(self, path):
170
+ def match(self, path: str) -> tuple[str, tuple[()], dict[str, Any]] | None:
164
171
  match = self.regex.search(path)
165
172
  if match:
166
173
  # RoutePattern doesn't allow non-named groups so args are ignored.
@@ -174,56 +181,64 @@ class RoutePattern(CheckURLMixin):
174
181
  return path[match.end() :], (), kwargs
175
182
  return None
176
183
 
177
- def check(self):
184
+ def preflight(self) -> list[PreflightResult]:
178
185
  warnings = self._check_pattern_startswith_slash()
179
186
  route = self._route
180
187
  if "(?P<" in route or route.startswith("^") or route.endswith("$"):
181
188
  warnings.append(
182
- Warning(
183
- f"Your URL pattern {self.describe()} has a route that contains '(?P<', begins "
189
+ PreflightResult(
190
+ fix=f"Your URL pattern {self.describe()} has a route that contains '(?P<', begins "
184
191
  "with a '^', or ends with a '$'. This was likely an oversight "
185
192
  "when migrating to plain.urls.path().",
186
- id="2_0.W001",
193
+ warning=True,
194
+ id="urls.path_migration_warning",
187
195
  )
188
196
  )
189
197
  return warnings
190
198
 
191
- def _compile(self, route):
199
+ def _compile(self, route: str) -> re.Pattern[str]:
192
200
  return re.compile(_route_to_regex(route, self._is_endpoint)[0])
193
201
 
194
- def __str__(self):
202
+ def __str__(self) -> str:
195
203
  return str(self._route)
196
204
 
197
205
 
198
206
  class URLPattern:
199
- def __init__(self, *, pattern, view, name=None):
207
+ def __init__(
208
+ self,
209
+ *,
210
+ pattern: RegexPattern | RoutePattern,
211
+ view: Any,
212
+ name: str | None = None,
213
+ ):
200
214
  self.pattern = pattern
201
215
  self.view = view
202
216
  self.name = name
203
217
 
204
- def __repr__(self):
218
+ def __repr__(self) -> str:
205
219
  return f"<{self.__class__.__name__} {self.pattern.describe()}>"
206
220
 
207
- def check(self):
221
+ def preflight(self) -> list[PreflightResult]:
208
222
  warnings = self._check_pattern_name()
209
- warnings.extend(self.pattern.check())
223
+ warnings.extend(self.pattern.preflight())
210
224
  return warnings
211
225
 
212
- def _check_pattern_name(self):
226
+ def _check_pattern_name(self) -> list[PreflightResult]:
213
227
  """
214
228
  Check that the pattern name does not contain a colon.
215
229
  """
216
230
  if self.pattern.name is not None and ":" in self.pattern.name:
217
- warning = Warning(
218
- f"Your URL pattern {self.pattern.describe()} has a name including a ':'. Remove the colon, to "
231
+ warning = PreflightResult(
232
+ fix=f"Your URL pattern {self.pattern.describe()} has a name including a ':'. Remove the colon, to "
219
233
  "avoid ambiguous namespace references.",
220
- id="urls.W003",
234
+ warning=True,
235
+ id="urls.pattern_name_contains_colon",
221
236
  )
222
237
  return [warning]
223
238
  else:
224
239
  return []
225
240
 
226
- def resolve(self, path):
241
+ def resolve(self, path: str) -> Any:
227
242
  match = self.pattern.match(path)
228
243
  if match:
229
244
  new_path, args, captured_kwargs = match
@@ -236,3 +251,4 @@ class URLPattern:
236
251
  url_name=self.pattern.name,
237
252
  route=str(self.pattern),
238
253
  )
254
+ return None
plain/urls/resolvers.py CHANGED
@@ -6,32 +6,39 @@ a string) and returns a ResolverMatch object which provides access to all
6
6
  attributes of the resolved URL match.
7
7
  """
8
8
 
9
+ from __future__ import annotations
10
+
9
11
  import functools
10
12
  import re
11
13
  from threading import local
14
+ from typing import TYPE_CHECKING, Any
12
15
  from urllib.parse import quote
13
16
 
14
- from plain.preflight.urls import check_resolver
15
17
  from plain.runtime import settings
16
18
  from plain.utils.datastructures import MultiValueDict
17
19
  from plain.utils.http import RFC3986_SUBDELIMS, escape_leading_slashes
18
20
  from plain.utils.module_loading import import_string
19
- from plain.utils.regex_helper import normalize
21
+ from plain.utils.regex_helper import _normalize
20
22
 
21
23
  from .exceptions import NoReverseMatch, Resolver404
22
- from .patterns import RegexPattern, URLPattern
24
+ from .patterns import RegexPattern, RoutePattern, URLPattern
25
+
26
+ if TYPE_CHECKING:
27
+ from plain.preflight import PreflightResult
28
+
29
+ from .routers import Router
23
30
 
24
31
 
25
32
  class ResolverMatch:
26
33
  def __init__(
27
34
  self,
28
35
  *,
29
- view,
30
- args,
31
- kwargs,
32
- url_name=None,
33
- namespaces=None,
34
- route=None,
36
+ view: Any,
37
+ args: tuple[Any, ...],
38
+ kwargs: dict[str, Any],
39
+ url_name: str | None = None,
40
+ namespaces: list[str] | None = None,
41
+ route: str | None = None,
35
42
  ):
36
43
  self.view = view
37
44
  self.args = args
@@ -49,7 +56,7 @@ class ResolverMatch:
49
56
  )
50
57
 
51
58
 
52
- def get_resolver(router=None):
59
+ def get_resolver(router: str | Router | None = None) -> URLResolver:
53
60
  if router is None:
54
61
  router = settings.URLS_ROUTER
55
62
 
@@ -57,7 +64,7 @@ def get_resolver(router=None):
57
64
 
58
65
 
59
66
  @functools.cache
60
- def _get_cached_resolver(router):
67
+ def _get_cached_resolver(router: str | Router) -> URLResolver:
61
68
  if isinstance(router, str):
62
69
  # Do this inside the cached call, primarily for the URLS_ROUTER
63
70
  router_class = import_string(router)
@@ -67,7 +74,9 @@ def _get_cached_resolver(router):
67
74
 
68
75
 
69
76
  @functools.cache
70
- def get_ns_resolver(ns_pattern, resolver, converters):
77
+ def get_ns_resolver(
78
+ ns_pattern: str, resolver: URLResolver, converters: tuple[tuple[str, Any], ...]
79
+ ) -> URLResolver:
71
80
  from .routers import Router
72
81
 
73
82
  # Build a namespaced resolver for the given parent urls_module pattern.
@@ -96,13 +105,13 @@ class URLResolver:
96
105
  def __init__(
97
106
  self,
98
107
  *,
99
- pattern,
100
- router,
108
+ pattern: RegexPattern | RoutePattern,
109
+ router: Router,
101
110
  ):
102
111
  self.pattern = pattern
103
112
  self.router = router
104
- self._reverse_dict = {}
105
- self._namespace_dict = {}
113
+ self._reverse_dict: MultiValueDict = MultiValueDict()
114
+ self._namespace_dict: dict[str, tuple[str, URLResolver]] = {}
106
115
  self._populated = False
107
116
  self._local = local()
108
117
 
@@ -111,16 +120,17 @@ class URLResolver:
111
120
  self.namespace = self.router.namespace
112
121
  self.url_patterns = self.router.urls
113
122
 
114
- def __repr__(self):
123
+ def __repr__(self) -> str:
115
124
  return f"<{self.__class__.__name__} {repr(self.router)} ({self.namespace}) {self.pattern.describe()}>"
116
125
 
117
- def check(self):
126
+ def preflight(self) -> list[PreflightResult]:
118
127
  messages = []
128
+ messages.extend(self.pattern.preflight())
119
129
  for pattern in self.url_patterns:
120
- messages.extend(check_resolver(pattern))
121
- return messages or self.pattern.check()
130
+ messages.extend(pattern.preflight())
131
+ return messages
122
132
 
123
- def _populate(self):
133
+ def _populate(self) -> None:
124
134
  # Short-circuit if called recursively in this thread to prevent
125
135
  # infinite recursion. Concurrent threads may call this at the same
126
136
  # time and will need to continue, so set 'populating' on a
@@ -135,7 +145,7 @@ class URLResolver:
135
145
  p_pattern = url_pattern.pattern.regex.pattern
136
146
  p_pattern = p_pattern.removeprefix("^")
137
147
  if isinstance(url_pattern, URLPattern):
138
- bits = normalize(url_pattern.pattern.regex.pattern)
148
+ bits = _normalize(url_pattern.pattern.regex.pattern)
139
149
  lookups.appendlist(
140
150
  url_pattern.view,
141
151
  (
@@ -164,7 +174,7 @@ class URLResolver:
164
174
  pat,
165
175
  converters,
166
176
  ) in url_pattern.reverse_dict.getlist(name):
167
- new_matches = normalize(p_pattern + pat)
177
+ new_matches = _normalize(p_pattern + pat)
168
178
  lookups.appendlist(
169
179
  name,
170
180
  (
@@ -191,26 +201,26 @@ class URLResolver:
191
201
  self._local.populating = False
192
202
 
193
203
  @property
194
- def reverse_dict(self):
204
+ def reverse_dict(self) -> MultiValueDict:
195
205
  if not self._reverse_dict:
196
206
  self._populate()
197
207
  return self._reverse_dict
198
208
 
199
209
  @property
200
- def namespace_dict(self):
210
+ def namespace_dict(self) -> dict[str, tuple[str, URLResolver]]:
201
211
  if not self._namespace_dict:
202
212
  self._populate()
203
213
  return self._namespace_dict
204
214
 
205
215
  @staticmethod
206
- def _join_route(route1, route2):
216
+ def _join_route(route1: str, route2: str) -> str:
207
217
  """Join two routes, without the starting ^ in the second route."""
208
218
  if not route1:
209
219
  return route2
210
220
  route2 = route2.removeprefix("^")
211
221
  return route1 + route2
212
222
 
213
- def resolve(self, path):
223
+ def resolve(self, path: str) -> ResolverMatch:
214
224
  path = str(path) # path may be a reverse_lazy object
215
225
  match = self.pattern.match(path)
216
226
  if match:
@@ -247,7 +257,7 @@ class URLResolver:
247
257
  raise Resolver404({"path": new_path})
248
258
  raise Resolver404({"path": path})
249
259
 
250
- def reverse(self, lookup_view, *args, **kwargs):
260
+ def reverse(self, lookup_view: Any, *args: Any, **kwargs: Any) -> str:
251
261
  if args and kwargs:
252
262
  raise ValueError("Don't mix *args and **kwargs in call to reverse()!")
253
263
 
plain/urls/utils.py CHANGED
@@ -1,10 +1,14 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
1
5
  from plain.utils.functional import lazy
2
6
 
3
7
  from .exceptions import NoReverseMatch
4
8
  from .resolvers import get_ns_resolver, get_resolver
5
9
 
6
10
 
7
- def reverse(url_name: str, *args, **kwargs):
11
+ def reverse(url_name: str, *args: Any, **kwargs: Any) -> str:
8
12
  resolver = get_resolver()
9
13
 
10
14
  *path, view = url_name.split(":")
plain/utils/README.md CHANGED
@@ -1,9 +1,256 @@
1
- # Utils
1
+ # plain.utils
2
2
 
3
- **Various utilities for text manipulation, parsing, dates, and more.**
3
+ **Common utilities for working with dates, text, HTML, and more.**
4
4
 
5
5
  - [Overview](#overview)
6
+ - [Timezone utilities](#timezone-utilities)
7
+ - [Getting the current time](#getting-the-current-time)
8
+ - [Converting between aware and naive datetimes](#converting-between-aware-and-naive-datetimes)
9
+ - [Temporarily changing the timezone](#temporarily-changing-the-timezone)
10
+ - [Time formatting](#time-formatting)
11
+ - [Text utilities](#text-utilities)
12
+ - [Slugify](#slugify)
13
+ - [Truncating text](#truncating-text)
14
+ - [HTML utilities](#html-utilities)
15
+ - [Escaping HTML](#escaping-html)
16
+ - [Formatting HTML safely](#formatting-html-safely)
17
+ - [Stripping tags](#stripping-tags)
18
+ - [Embedding JSON in HTML](#embedding-json-in-html)
19
+ - [Safe strings](#safe-strings)
20
+ - [Random strings](#random-strings)
21
+ - [Date parsing](#date-parsing)
22
+ - [FAQs](#faqs)
23
+ - [Installation](#installation)
6
24
 
7
25
  ## Overview
8
26
 
9
- The utilities aren't going to be documented in detail here. Take a look at the source code for more information.
27
+ The `plain.utils` module provides a collection of utilities that you'll commonly need when building web applications. You can import what you need directly from the submodules:
28
+
29
+ ```python
30
+ from plain.utils.timezone import now, localtime
31
+ from plain.utils.text import slugify
32
+ from plain.utils.html import escape, format_html
33
+
34
+ # Get the current time as a timezone-aware datetime
35
+ current_time = now()
36
+
37
+ # Create a URL-safe slug
38
+ slug = slugify("Hello World!") # "hello-world"
39
+
40
+ # Safely format HTML with escaped values
41
+ html = format_html("<p>Hello, {}!</p>", user_input)
42
+ ```
43
+
44
+ ## Timezone utilities
45
+
46
+ Plain uses timezone-aware datetimes throughout. The timezone utilities help you work with aware datetimes consistently.
47
+
48
+ ### Getting the current time
49
+
50
+ ```python
51
+ from plain.utils.timezone import now
52
+
53
+ current_time = now() # Returns a timezone-aware datetime in UTC
54
+ ```
55
+
56
+ ### Converting between aware and naive datetimes
57
+
58
+ ```python
59
+ from plain.utils.timezone import make_aware, make_naive, is_aware, localtime
60
+ from datetime import datetime
61
+
62
+ # Check if a datetime is aware
63
+ is_aware(some_datetime)
64
+
65
+ # Make a naive datetime aware (uses current timezone by default)
66
+ aware_dt = make_aware(datetime(2024, 1, 15, 10, 30))
67
+
68
+ # Convert to local time
69
+ local_dt = localtime(aware_dt)
70
+
71
+ # Make an aware datetime naive
72
+ naive_dt = make_naive(aware_dt)
73
+ ```
74
+
75
+ ### Temporarily changing the timezone
76
+
77
+ ```python
78
+ from plain.utils.timezone import override, get_current_timezone
79
+
80
+ with override("America/New_York"):
81
+ # Code here uses the New York timezone
82
+ tz = get_current_timezone()
83
+ ```
84
+
85
+ For more timezone functions, see [`timezone.py`](./timezone.py#activate).
86
+
87
+ ## Time formatting
88
+
89
+ Format time differences as human-readable strings.
90
+
91
+ ```python
92
+ from plain.utils.timesince import timesince, timeuntil
93
+ from datetime import datetime, timedelta
94
+ from plain.utils.timezone import now
95
+
96
+ past = now() - timedelta(days=2, hours=3)
97
+ timesince(past) # "2 days, 3 hours"
98
+
99
+ future = now() + timedelta(weeks=1)
100
+ timeuntil(future) # "1 week"
101
+ ```
102
+
103
+ You can use a short format for compact display:
104
+
105
+ ```python
106
+ timesince(past, format="short") # "2d 3h"
107
+ ```
108
+
109
+ ## Text utilities
110
+
111
+ ### Slugify
112
+
113
+ Convert text to a URL-safe slug.
114
+
115
+ ```python
116
+ from plain.utils.text import slugify
117
+
118
+ slugify("Hello World!") # "hello-world"
119
+ slugify("Cafe au lait") # "cafe-au-lait"
120
+ slugify("My Article Title") # "my-article-title"
121
+
122
+ # Preserve unicode characters
123
+ slugify("Ich liebe Berlin", allow_unicode=True) # "ich-liebe-berlin"
124
+ ```
125
+
126
+ ### Truncating text
127
+
128
+ Truncate text by characters or words, with HTML support.
129
+
130
+ ```python
131
+ from plain.utils.text import Truncator
132
+
133
+ text = "This is a long piece of text that needs to be shortened."
134
+ Truncator(text).chars(20) # "This is a long pie..."
135
+ Truncator(text).words(5) # "This is a long piece..."
136
+
137
+ # Truncate HTML while preserving valid structure
138
+ html = "<p>This is <strong>bold</strong> text.</p>"
139
+ Truncator(html).chars(15, html=True) # "<p>This is <strong>bo</strong>...</p>"
140
+ ```
141
+
142
+ ## HTML utilities
143
+
144
+ ### Escaping HTML
145
+
146
+ ```python
147
+ from plain.utils.html import escape
148
+
149
+ escape("<script>alert('xss')</script>")
150
+ # "&lt;script&gt;alert(&#x27;xss&#x27;)&lt;/script&gt;"
151
+ ```
152
+
153
+ ### Formatting HTML safely
154
+
155
+ Build HTML fragments with automatic escaping of values:
156
+
157
+ ```python
158
+ from plain.utils.html import format_html
159
+
160
+ # Values are automatically escaped
161
+ format_html("<a href='{}'>{}</a>", url, link_text)
162
+
163
+ # Safe for untrusted input
164
+ format_html("<p>Welcome, {}!</p>", user_provided_name)
165
+ ```
166
+
167
+ ### Stripping tags
168
+
169
+ Remove HTML tags from text:
170
+
171
+ ```python
172
+ from plain.utils.html import strip_tags
173
+
174
+ strip_tags("<p>Hello <strong>world</strong>!</p>") # "Hello world!"
175
+ ```
176
+
177
+ ### Embedding JSON in HTML
178
+
179
+ Safely embed JSON data in a script tag:
180
+
181
+ ```python
182
+ from plain.utils.html import json_script
183
+
184
+ data = {"user": "john", "count": 42}
185
+ json_script(data, element_id="user-data")
186
+ # '<script id="user-data" type="application/json">{"user": "john", "count": 42}</script>'
187
+ ```
188
+
189
+ ## Safe strings
190
+
191
+ Mark strings as safe to prevent double-escaping.
192
+
193
+ ```python
194
+ from plain.utils.safestring import mark_safe, SafeString
195
+
196
+ # Mark a string as already escaped/safe
197
+ html = mark_safe("<strong>Already safe HTML</strong>")
198
+
199
+ # Check if something is a SafeString
200
+ isinstance(html, SafeString) # True
201
+ ```
202
+
203
+ Use `mark_safe` only when you've manually ensured the content is safe. For building HTML from untrusted input, use `format_html` instead.
204
+
205
+ ## Random strings
206
+
207
+ Generate cryptographically secure random strings.
208
+
209
+ ```python
210
+ from plain.utils.crypto import get_random_string
211
+
212
+ # Default: 12 characters, alphanumeric
213
+ token = get_random_string(12) # e.g., "Kx9mP2nL4qRs"
214
+
215
+ # Custom character set
216
+ pin = get_random_string(6, allowed_chars="0123456789") # e.g., "847293"
217
+ ```
218
+
219
+ ## Date parsing
220
+
221
+ Parse date and time strings into Python objects.
222
+
223
+ ```python
224
+ from plain.utils.dateparse import parse_date, parse_datetime, parse_time, parse_duration
225
+
226
+ parse_date("2024-01-15") # datetime.date(2024, 1, 15)
227
+ parse_datetime("2024-01-15T10:30:00Z") # datetime.datetime(2024, 1, 15, 10, 30, tzinfo=UTC)
228
+ parse_time("10:30:00") # datetime.time(10, 30)
229
+ parse_duration("1 02:30:00") # datetime.timedelta(days=1, hours=2, minutes=30)
230
+ ```
231
+
232
+ These functions return `None` if the input is not well-formatted, and raise `ValueError` if the input is well-formatted but invalid.
233
+
234
+ ## FAQs
235
+
236
+ #### What about the other utilities in this module?
237
+
238
+ The `plain.utils` module contains additional utilities that are primarily used internally by Plain. You can explore the source files directly:
239
+
240
+ - [`datastructures.py`](./datastructures.py) - `MultiValueDict`, `OrderedSet`, `ImmutableList`
241
+ - [`functional.py`](./functional.py) - `SimpleLazyObject`, `lazy`, `classproperty`
242
+ - [`http.py`](./http.py) - `urlencode`, `http_date`, `base36_to_int`
243
+ - [`encoding.py`](./encoding.py) - `force_str`, `force_bytes`
244
+
245
+ #### Should I use `datetime.datetime.now()` or `plain.utils.timezone.now()`?
246
+
247
+ Always use `plain.utils.timezone.now()`. It returns a timezone-aware datetime in UTC, which is what Plain expects throughout the framework.
248
+
249
+ ## Installation
250
+
251
+ The `plain.utils` module is included with Plain.
252
+
253
+ ```python
254
+ from plain.utils.timezone import now
255
+ from plain.utils.text import slugify
256
+ ```