plain 0.68.0__py3-none-any.whl → 0.103.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.
- plain/CHANGELOG.md +684 -1
- plain/README.md +1 -1
- plain/agents/.claude/rules/plain.md +88 -0
- plain/agents/.claude/skills/plain-install/SKILL.md +26 -0
- plain/agents/.claude/skills/plain-upgrade/SKILL.md +35 -0
- 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 -36
- plain/cli/README.md +185 -16
- plain/cli/__init__.py +2 -1
- plain/cli/agent.py +234 -0
- plain/cli/build.py +7 -8
- plain/cli/changelog.py +11 -5
- plain/cli/chores.py +32 -34
- plain/cli/core.py +110 -26
- plain/cli/docs.py +98 -21
- plain/cli/formatting.py +40 -17
- plain/cli/install.py +10 -54
- plain/cli/{agent/llmdocs.py → llmdocs.py} +45 -26
- plain/cli/output.py +6 -2
- plain/cli/preflight.py +27 -75
- plain/cli/print.py +4 -4
- plain/cli/registry.py +96 -10
- plain/cli/{agent/request.py → request.py} +67 -33
- 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 -8
- 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 +27 -16
- plain/paginator.py +31 -21
- plain/preflight/README.md +209 -24
- plain/preflight/__init__.py +1 -0
- plain/preflight/checks.py +3 -1
- plain/preflight/files.py +3 -1
- plain/preflight/registry.py +26 -11
- plain/preflight/results.py +15 -7
- plain/preflight/security.py +15 -13
- plain/preflight/settings.py +54 -0
- plain/preflight/urls.py +4 -1
- plain/runtime/README.md +115 -47
- plain/runtime/__init__.py +10 -6
- plain/runtime/global_settings.py +34 -25
- 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/templates/README.md +211 -20
- plain/templates/jinja/__init__.py +13 -5
- 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 +38 -22
- plain/urls/resolvers.py +35 -25
- 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.68.0.dist-info → plain-0.103.0.dist-info}/METADATA +4 -2
- plain-0.103.0.dist-info/RECORD +198 -0
- {plain-0.68.0.dist-info → plain-0.103.0.dist-info}/WHEEL +1 -1
- plain-0.103.0.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/csrf/views.py +0 -31
- plain/logs/utils.py +0 -46
- plain/templates/AGENTS.md +0 -3
- plain-0.68.0.dist-info/RECORD +0 -169
- plain-0.68.0.dist-info/entry_points.txt +0 -5
- {plain-0.68.0.dist-info → plain-0.103.0.dist-info}/licenses/LICENSE +0 -0
plain/urls/resolvers.py
CHANGED
|
@@ -6,31 +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
17
|
from plain.runtime import settings
|
|
15
18
|
from plain.utils.datastructures import MultiValueDict
|
|
16
19
|
from plain.utils.http import RFC3986_SUBDELIMS, escape_leading_slashes
|
|
17
20
|
from plain.utils.module_loading import import_string
|
|
18
|
-
from plain.utils.regex_helper import
|
|
21
|
+
from plain.utils.regex_helper import _normalize
|
|
19
22
|
|
|
20
23
|
from .exceptions import NoReverseMatch, Resolver404
|
|
21
|
-
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
|
|
22
30
|
|
|
23
31
|
|
|
24
32
|
class ResolverMatch:
|
|
25
33
|
def __init__(
|
|
26
34
|
self,
|
|
27
35
|
*,
|
|
28
|
-
view,
|
|
29
|
-
args,
|
|
30
|
-
kwargs,
|
|
31
|
-
url_name=None,
|
|
32
|
-
namespaces=None,
|
|
33
|
-
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,
|
|
34
42
|
):
|
|
35
43
|
self.view = view
|
|
36
44
|
self.args = args
|
|
@@ -48,7 +56,7 @@ class ResolverMatch:
|
|
|
48
56
|
)
|
|
49
57
|
|
|
50
58
|
|
|
51
|
-
def get_resolver(router=None):
|
|
59
|
+
def get_resolver(router: str | Router | None = None) -> URLResolver:
|
|
52
60
|
if router is None:
|
|
53
61
|
router = settings.URLS_ROUTER
|
|
54
62
|
|
|
@@ -56,7 +64,7 @@ def get_resolver(router=None):
|
|
|
56
64
|
|
|
57
65
|
|
|
58
66
|
@functools.cache
|
|
59
|
-
def _get_cached_resolver(router):
|
|
67
|
+
def _get_cached_resolver(router: str | Router) -> URLResolver:
|
|
60
68
|
if isinstance(router, str):
|
|
61
69
|
# Do this inside the cached call, primarily for the URLS_ROUTER
|
|
62
70
|
router_class = import_string(router)
|
|
@@ -66,7 +74,9 @@ def _get_cached_resolver(router):
|
|
|
66
74
|
|
|
67
75
|
|
|
68
76
|
@functools.cache
|
|
69
|
-
def get_ns_resolver(
|
|
77
|
+
def get_ns_resolver(
|
|
78
|
+
ns_pattern: str, resolver: URLResolver, converters: tuple[tuple[str, Any], ...]
|
|
79
|
+
) -> URLResolver:
|
|
70
80
|
from .routers import Router
|
|
71
81
|
|
|
72
82
|
# Build a namespaced resolver for the given parent urls_module pattern.
|
|
@@ -95,13 +105,13 @@ class URLResolver:
|
|
|
95
105
|
def __init__(
|
|
96
106
|
self,
|
|
97
107
|
*,
|
|
98
|
-
pattern,
|
|
99
|
-
router,
|
|
108
|
+
pattern: RegexPattern | RoutePattern,
|
|
109
|
+
router: Router,
|
|
100
110
|
):
|
|
101
111
|
self.pattern = pattern
|
|
102
112
|
self.router = router
|
|
103
|
-
self._reverse_dict =
|
|
104
|
-
self._namespace_dict = {}
|
|
113
|
+
self._reverse_dict: MultiValueDict = MultiValueDict()
|
|
114
|
+
self._namespace_dict: dict[str, tuple[str, URLResolver]] = {}
|
|
105
115
|
self._populated = False
|
|
106
116
|
self._local = local()
|
|
107
117
|
|
|
@@ -110,17 +120,17 @@ class URLResolver:
|
|
|
110
120
|
self.namespace = self.router.namespace
|
|
111
121
|
self.url_patterns = self.router.urls
|
|
112
122
|
|
|
113
|
-
def __repr__(self):
|
|
123
|
+
def __repr__(self) -> str:
|
|
114
124
|
return f"<{self.__class__.__name__} {repr(self.router)} ({self.namespace}) {self.pattern.describe()}>"
|
|
115
125
|
|
|
116
|
-
def preflight(self):
|
|
126
|
+
def preflight(self) -> list[PreflightResult]:
|
|
117
127
|
messages = []
|
|
118
128
|
messages.extend(self.pattern.preflight())
|
|
119
129
|
for pattern in self.url_patterns:
|
|
120
130
|
messages.extend(pattern.preflight())
|
|
121
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
|
+
```
|
plain/utils/cache.py
CHANGED
|
@@ -15,16 +15,22 @@ An example: i18n middleware would need to distinguish caches by the
|
|
|
15
15
|
"Accept-language" header.
|
|
16
16
|
"""
|
|
17
17
|
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
18
20
|
import time
|
|
19
21
|
from collections import defaultdict
|
|
22
|
+
from typing import TYPE_CHECKING, Any
|
|
20
23
|
|
|
21
24
|
from .http import http_date
|
|
22
25
|
from .regex_helper import _lazy_re_compile
|
|
23
26
|
|
|
24
|
-
|
|
27
|
+
if TYPE_CHECKING:
|
|
28
|
+
from plain.http import ResponseBase
|
|
29
|
+
|
|
30
|
+
_cc_delim_re = _lazy_re_compile(r"\s*,\s*")
|
|
25
31
|
|
|
26
32
|
|
|
27
|
-
def patch_response_headers(response, cache_timeout):
|
|
33
|
+
def patch_response_headers(response: ResponseBase, cache_timeout: int | float) -> None:
|
|
28
34
|
"""
|
|
29
35
|
Add HTTP caching headers to the given HttpResponse: Expires and
|
|
30
36
|
Cache-Control.
|
|
@@ -38,7 +44,7 @@ def patch_response_headers(response, cache_timeout):
|
|
|
38
44
|
patch_cache_control(response, max_age=cache_timeout)
|
|
39
45
|
|
|
40
46
|
|
|
41
|
-
def add_never_cache_headers(response):
|
|
47
|
+
def add_never_cache_headers(response: ResponseBase) -> None:
|
|
42
48
|
"""
|
|
43
49
|
Add headers to a response to indicate that a page should never be cached.
|
|
44
50
|
"""
|
|
@@ -48,7 +54,7 @@ def add_never_cache_headers(response):
|
|
|
48
54
|
)
|
|
49
55
|
|
|
50
56
|
|
|
51
|
-
def patch_cache_control(response, **kwargs):
|
|
57
|
+
def patch_cache_control(response: ResponseBase, **kwargs: Any) -> None:
|
|
52
58
|
"""
|
|
53
59
|
Patch the Cache-Control header by adding all keyword arguments to it.
|
|
54
60
|
The transformation is as follows:
|
|
@@ -61,22 +67,22 @@ def patch_cache_control(response, **kwargs):
|
|
|
61
67
|
str() to it.
|
|
62
68
|
"""
|
|
63
69
|
|
|
64
|
-
def dictitem(s):
|
|
70
|
+
def dictitem(s: str) -> tuple[str, str | bool]:
|
|
65
71
|
t = s.split("=", 1)
|
|
66
72
|
if len(t) > 1:
|
|
67
73
|
return (t[0].lower(), t[1])
|
|
68
74
|
else:
|
|
69
75
|
return (t[0].lower(), True)
|
|
70
76
|
|
|
71
|
-
def dictvalue(*t):
|
|
77
|
+
def dictvalue(*t: str | bool) -> str:
|
|
72
78
|
if t[1] is True:
|
|
73
|
-
return t[0]
|
|
79
|
+
return str(t[0])
|
|
74
80
|
else:
|
|
75
81
|
return f"{t[0]}={t[1]}"
|
|
76
82
|
|
|
77
83
|
cc = defaultdict(set)
|
|
78
84
|
if response.headers.get("Cache-Control"):
|
|
79
|
-
for field in
|
|
85
|
+
for field in _cc_delim_re.split(response.headers["Cache-Control"]):
|
|
80
86
|
directive, value = dictitem(field)
|
|
81
87
|
if directive == "no-cache":
|
|
82
88
|
# no-cache supports multiple field names.
|
|
@@ -117,7 +123,7 @@ def patch_cache_control(response, **kwargs):
|
|
|
117
123
|
response.headers["Cache-Control"] = cc
|
|
118
124
|
|
|
119
125
|
|
|
120
|
-
def patch_vary_headers(response, newheaders):
|
|
126
|
+
def patch_vary_headers(response: ResponseBase, newheaders: list[str]) -> None:
|
|
121
127
|
"""
|
|
122
128
|
Add (or update) the "Vary" header in the given Response object.
|
|
123
129
|
newheaders is a list of header names that should be in "Vary". If headers
|
|
@@ -128,7 +134,7 @@ def patch_vary_headers(response, newheaders):
|
|
|
128
134
|
# implementations may rely on the order of the Vary contents in, say,
|
|
129
135
|
# computing an MD5 hash.
|
|
130
136
|
if "Vary" in response.headers:
|
|
131
|
-
vary_headers =
|
|
137
|
+
vary_headers = _cc_delim_re.split(response.headers["Vary"])
|
|
132
138
|
else:
|
|
133
139
|
vary_headers = []
|
|
134
140
|
# Use .lower() here so we treat headers as case-insensitive.
|
|
@@ -145,7 +151,7 @@ def patch_vary_headers(response, newheaders):
|
|
|
145
151
|
response.headers["Vary"] = ", ".join(vary_headers)
|
|
146
152
|
|
|
147
153
|
|
|
148
|
-
def _to_tuple(s):
|
|
154
|
+
def _to_tuple(s: str) -> tuple[str, str | bool]:
|
|
149
155
|
t = s.split("=", 1)
|
|
150
156
|
if len(t) == 2:
|
|
151
157
|
return t[0].lower(), t[1]
|
plain/utils/crypto.py
CHANGED
|
@@ -2,9 +2,13 @@
|
|
|
2
2
|
Plain's standard crypto functions and utilities.
|
|
3
3
|
"""
|
|
4
4
|
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
5
7
|
import hashlib
|
|
6
8
|
import hmac
|
|
7
9
|
import secrets
|
|
10
|
+
from collections.abc import Callable
|
|
11
|
+
from typing import Any
|
|
8
12
|
|
|
9
13
|
from plain.runtime import settings
|
|
10
14
|
from plain.utils.encoding import force_bytes
|
|
@@ -16,7 +20,13 @@ class InvalidAlgorithm(ValueError):
|
|
|
16
20
|
pass
|
|
17
21
|
|
|
18
22
|
|
|
19
|
-
def salted_hmac(
|
|
23
|
+
def salted_hmac(
|
|
24
|
+
key_salt: str | bytes,
|
|
25
|
+
value: str | bytes,
|
|
26
|
+
secret: str | bytes | None = None,
|
|
27
|
+
*,
|
|
28
|
+
algorithm: str = "sha1",
|
|
29
|
+
) -> hmac.HMAC:
|
|
20
30
|
"""
|
|
21
31
|
Return the HMAC of 'value', using a key generated from key_salt and a
|
|
22
32
|
secret (which defaults to settings.SECRET_KEY). Default algorithm is SHA1,
|
|
@@ -48,7 +58,7 @@ def salted_hmac(key_salt, value, secret=None, *, algorithm="sha1"):
|
|
|
48
58
|
RANDOM_STRING_CHARS = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
|
49
59
|
|
|
50
60
|
|
|
51
|
-
def get_random_string(length, allowed_chars=RANDOM_STRING_CHARS):
|
|
61
|
+
def get_random_string(length: int, allowed_chars: str = RANDOM_STRING_CHARS) -> str:
|
|
52
62
|
"""
|
|
53
63
|
Return a securely generated random string.
|
|
54
64
|
|
|
@@ -62,11 +72,17 @@ def get_random_string(length, allowed_chars=RANDOM_STRING_CHARS):
|
|
|
62
72
|
return "".join(secrets.choice(allowed_chars) for i in range(length))
|
|
63
73
|
|
|
64
74
|
|
|
65
|
-
def pbkdf2(
|
|
75
|
+
def pbkdf2(
|
|
76
|
+
password: str | bytes,
|
|
77
|
+
salt: str | bytes,
|
|
78
|
+
iterations: int,
|
|
79
|
+
dklen: int = 0,
|
|
80
|
+
digest: Callable[[], Any] | None = None,
|
|
81
|
+
) -> bytes:
|
|
66
82
|
"""Return the hash of password using pbkdf2."""
|
|
67
83
|
if digest is None:
|
|
68
84
|
digest = hashlib.sha256
|
|
69
|
-
|
|
85
|
+
dklen_value: int | None = dklen if dklen else None
|
|
70
86
|
password = force_bytes(password)
|
|
71
87
|
salt = force_bytes(salt)
|
|
72
|
-
return hashlib.pbkdf2_hmac(digest().name, password, salt, iterations,
|
|
88
|
+
return hashlib.pbkdf2_hmac(digest().name, password, salt, iterations, dklen_value)
|