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.
- plain/CHANGELOG.md +684 -0
- plain/README.md +1 -1
- plain/assets/compile.py +25 -12
- plain/assets/finders.py +24 -17
- plain/assets/fingerprints.py +10 -7
- plain/assets/urls.py +1 -1
- plain/assets/views.py +47 -33
- plain/chores/README.md +25 -23
- plain/chores/__init__.py +2 -1
- plain/chores/core.py +27 -0
- plain/chores/registry.py +23 -53
- plain/cli/README.md +185 -16
- plain/cli/__init__.py +2 -1
- plain/cli/agent.py +236 -0
- plain/cli/build.py +7 -8
- plain/cli/changelog.py +11 -5
- plain/cli/chores.py +32 -34
- plain/cli/core.py +112 -28
- plain/cli/docs.py +52 -11
- plain/cli/formatting.py +40 -17
- plain/cli/install.py +10 -54
- plain/cli/{agent/llmdocs.py → llmdocs.py} +21 -9
- plain/cli/output.py +6 -2
- plain/cli/preflight.py +175 -102
- plain/cli/print.py +4 -4
- plain/cli/registry.py +95 -26
- plain/cli/request.py +206 -0
- plain/cli/runtime.py +45 -0
- plain/cli/scaffold.py +2 -7
- plain/cli/server.py +153 -0
- plain/cli/settings.py +53 -49
- plain/cli/shell.py +15 -12
- plain/cli/startup.py +9 -8
- plain/cli/upgrade.py +17 -104
- plain/cli/urls.py +12 -7
- plain/cli/utils.py +3 -3
- plain/csrf/README.md +65 -40
- plain/csrf/middleware.py +53 -43
- plain/debug.py +5 -2
- plain/exceptions.py +22 -114
- plain/forms/README.md +453 -24
- plain/forms/__init__.py +55 -4
- plain/forms/boundfield.py +15 -8
- plain/forms/exceptions.py +1 -1
- plain/forms/fields.py +346 -143
- plain/forms/forms.py +75 -45
- plain/http/README.md +356 -9
- plain/http/__init__.py +41 -26
- plain/http/cookie.py +15 -7
- plain/http/exceptions.py +65 -0
- plain/http/middleware.py +32 -0
- plain/http/multipartparser.py +99 -88
- plain/http/request.py +362 -250
- plain/http/response.py +99 -197
- plain/internal/__init__.py +8 -1
- plain/internal/files/base.py +35 -19
- plain/internal/files/locks.py +19 -11
- plain/internal/files/move.py +8 -3
- plain/internal/files/temp.py +25 -6
- plain/internal/files/uploadedfile.py +47 -28
- plain/internal/files/uploadhandler.py +64 -58
- plain/internal/files/utils.py +24 -10
- plain/internal/handlers/base.py +34 -23
- plain/internal/handlers/exception.py +68 -65
- plain/internal/handlers/wsgi.py +65 -54
- plain/internal/middleware/headers.py +37 -11
- plain/internal/middleware/hosts.py +11 -13
- plain/internal/middleware/https.py +17 -7
- plain/internal/middleware/slash.py +14 -9
- plain/internal/reloader.py +77 -0
- plain/json.py +2 -1
- plain/logs/README.md +161 -62
- plain/logs/__init__.py +1 -1
- plain/logs/{loggers.py → app.py} +71 -67
- plain/logs/configure.py +63 -14
- plain/logs/debug.py +17 -6
- plain/logs/filters.py +15 -0
- plain/logs/formatters.py +7 -4
- plain/packages/README.md +105 -23
- plain/packages/config.py +15 -7
- plain/packages/registry.py +40 -15
- plain/paginator.py +31 -21
- plain/preflight/README.md +208 -23
- plain/preflight/__init__.py +5 -24
- plain/preflight/checks.py +12 -0
- plain/preflight/files.py +19 -13
- plain/preflight/registry.py +80 -58
- plain/preflight/results.py +37 -0
- plain/preflight/security.py +65 -71
- plain/preflight/settings.py +54 -0
- plain/preflight/urls.py +10 -48
- plain/runtime/README.md +115 -47
- plain/runtime/__init__.py +10 -6
- plain/runtime/global_settings.py +43 -33
- plain/runtime/secret.py +20 -0
- plain/runtime/user_settings.py +110 -38
- plain/runtime/utils.py +1 -1
- plain/server/LICENSE +35 -0
- plain/server/README.md +155 -0
- plain/server/__init__.py +9 -0
- plain/server/app.py +52 -0
- plain/server/arbiter.py +555 -0
- plain/server/config.py +118 -0
- plain/server/errors.py +31 -0
- plain/server/glogging.py +292 -0
- plain/server/http/__init__.py +12 -0
- plain/server/http/body.py +283 -0
- plain/server/http/errors.py +155 -0
- plain/server/http/message.py +400 -0
- plain/server/http/parser.py +70 -0
- plain/server/http/unreader.py +88 -0
- plain/server/http/wsgi.py +421 -0
- plain/server/pidfile.py +92 -0
- plain/server/sock.py +240 -0
- plain/server/util.py +317 -0
- plain/server/workers/__init__.py +6 -0
- plain/server/workers/base.py +304 -0
- plain/server/workers/sync.py +212 -0
- plain/server/workers/thread.py +399 -0
- plain/server/workers/workertmp.py +50 -0
- plain/signals/README.md +170 -1
- plain/signals/__init__.py +0 -1
- plain/signals/dispatch/dispatcher.py +49 -27
- plain/signing.py +131 -35
- plain/skills/README.md +36 -0
- plain/skills/plain-docs/SKILL.md +25 -0
- plain/skills/plain-install/SKILL.md +26 -0
- plain/skills/plain-request/SKILL.md +39 -0
- plain/skills/plain-shell/SKILL.md +24 -0
- plain/skills/plain-upgrade/SKILL.md +35 -0
- plain/templates/README.md +211 -20
- plain/templates/jinja/__init__.py +14 -27
- plain/templates/jinja/environments.py +5 -4
- plain/templates/jinja/extensions.py +12 -5
- plain/templates/jinja/filters.py +7 -2
- plain/templates/jinja/globals.py +2 -2
- plain/test/README.md +184 -22
- plain/test/client.py +340 -222
- plain/test/encoding.py +9 -6
- plain/test/exceptions.py +7 -2
- plain/urls/README.md +157 -73
- plain/urls/converters.py +18 -15
- plain/urls/exceptions.py +2 -2
- plain/urls/patterns.py +56 -40
- plain/urls/resolvers.py +38 -28
- plain/urls/utils.py +5 -1
- plain/utils/README.md +250 -3
- plain/utils/cache.py +17 -11
- plain/utils/crypto.py +21 -5
- plain/utils/datastructures.py +89 -56
- plain/utils/dateparse.py +9 -6
- plain/utils/deconstruct.py +15 -7
- plain/utils/decorators.py +5 -1
- plain/utils/dotenv.py +373 -0
- plain/utils/duration.py +8 -4
- plain/utils/encoding.py +14 -7
- plain/utils/functional.py +66 -49
- plain/utils/hashable.py +5 -1
- plain/utils/html.py +36 -22
- plain/utils/http.py +16 -9
- plain/utils/inspect.py +14 -6
- plain/utils/ipv6.py +7 -3
- plain/utils/itercompat.py +6 -1
- plain/utils/module_loading.py +7 -3
- plain/utils/regex_helper.py +37 -23
- plain/utils/safestring.py +14 -6
- plain/utils/text.py +41 -23
- plain/utils/timezone.py +33 -22
- plain/utils/tree.py +35 -19
- plain/validators.py +94 -52
- plain/views/README.md +156 -79
- plain/views/__init__.py +0 -1
- plain/views/base.py +25 -18
- plain/views/errors.py +13 -5
- plain/views/exceptions.py +4 -1
- plain/views/forms.py +6 -6
- plain/views/objects.py +52 -49
- plain/views/redirect.py +18 -15
- plain/views/templates.py +5 -3
- plain/wsgi.py +3 -1
- {plain-0.66.0.dist-info → plain-0.101.2.dist-info}/METADATA +4 -2
- plain-0.101.2.dist-info/RECORD +201 -0
- {plain-0.66.0.dist-info → plain-0.101.2.dist-info}/WHEEL +1 -1
- plain-0.101.2.dist-info/entry_points.txt +2 -0
- plain/AGENTS.md +0 -18
- plain/cli/agent/__init__.py +0 -20
- plain/cli/agent/docs.py +0 -80
- plain/cli/agent/md.py +0 -87
- plain/cli/agent/prompt.py +0 -45
- plain/cli/agent/request.py +0 -181
- plain/csrf/views.py +0 -31
- plain/logs/utils.py +0 -46
- plain/preflight/messages.py +0 -81
- plain/templates/AGENTS.md +0 -3
- plain-0.66.0.dist-info/RECORD +0 -168
- plain-0.66.0.dist-info/entry_points.txt +0 -4
- {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
|
|
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
|
|
13
|
+
from .converters import _get_converter
|
|
11
14
|
|
|
12
15
|
|
|
13
16
|
@internalcode
|
|
14
17
|
class CheckURLMixin:
|
|
15
|
-
|
|
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 =
|
|
37
|
-
f"
|
|
38
|
-
|
|
39
|
-
"
|
|
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
|
|
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
|
-
|
|
83
|
-
f"
|
|
84
|
-
|
|
85
|
-
"
|
|
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(
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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__(
|
|
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
|
|
221
|
+
def preflight(self) -> list[PreflightResult]:
|
|
208
222
|
warnings = self._check_pattern_name()
|
|
209
|
-
warnings.extend(self.pattern.
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
|
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(
|
|
121
|
-
return messages
|
|
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 =
|
|
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 =
|
|
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
|
-
#
|
|
1
|
+
# plain.utils
|
|
2
2
|
|
|
3
|
-
**
|
|
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
|
|
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
|
+
# "<script>alert('xss')</script>"
|
|
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
|
+
```
|