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
|
@@ -1,22 +1,29 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
1
3
|
import logging
|
|
2
4
|
import threading
|
|
3
5
|
import weakref
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from typing import TYPE_CHECKING, Any
|
|
4
8
|
|
|
5
9
|
from plain.utils.inspect import func_accepts_kwargs
|
|
6
10
|
|
|
7
|
-
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from collections.abc import Sequence
|
|
13
|
+
|
|
14
|
+
_logger = logging.getLogger("plain.signals.dispatch")
|
|
8
15
|
|
|
9
16
|
|
|
10
|
-
def _make_id(target):
|
|
17
|
+
def _make_id(target: Any) -> int | tuple[int, int]:
|
|
11
18
|
if hasattr(target, "__func__"):
|
|
12
19
|
return (id(target.__self__), id(target.__func__))
|
|
13
20
|
return id(target)
|
|
14
21
|
|
|
15
22
|
|
|
16
|
-
|
|
23
|
+
_NONE_ID = _make_id(None)
|
|
17
24
|
|
|
18
25
|
# A marker for caching
|
|
19
|
-
|
|
26
|
+
_NO_RECEIVERS = object()
|
|
20
27
|
|
|
21
28
|
|
|
22
29
|
class Signal:
|
|
@@ -29,7 +36,7 @@ class Signal:
|
|
|
29
36
|
{ receiverkey (id) : weakref(receiver) }
|
|
30
37
|
"""
|
|
31
38
|
|
|
32
|
-
def __init__(self, use_caching=False):
|
|
39
|
+
def __init__(self, use_caching: bool = False):
|
|
33
40
|
"""
|
|
34
41
|
Create a new signal.
|
|
35
42
|
"""
|
|
@@ -44,7 +51,13 @@ class Signal:
|
|
|
44
51
|
self.sender_receivers_cache = weakref.WeakKeyDictionary() if use_caching else {}
|
|
45
52
|
self._dead_receivers = False
|
|
46
53
|
|
|
47
|
-
def connect(
|
|
54
|
+
def connect(
|
|
55
|
+
self,
|
|
56
|
+
receiver: Callable[..., Any],
|
|
57
|
+
sender: Any = None,
|
|
58
|
+
weak: bool = True,
|
|
59
|
+
dispatch_uid: Any = None,
|
|
60
|
+
) -> None:
|
|
48
61
|
"""
|
|
49
62
|
Connect receiver to sender for signal.
|
|
50
63
|
|
|
@@ -111,7 +124,12 @@ class Signal:
|
|
|
111
124
|
self.receivers.append((lookup_key, receiver))
|
|
112
125
|
self.sender_receivers_cache.clear()
|
|
113
126
|
|
|
114
|
-
def disconnect(
|
|
127
|
+
def disconnect(
|
|
128
|
+
self,
|
|
129
|
+
receiver: Callable[..., Any] | None = None,
|
|
130
|
+
sender: Any = None,
|
|
131
|
+
dispatch_uid: Any = None,
|
|
132
|
+
) -> bool:
|
|
115
133
|
"""
|
|
116
134
|
Disconnect receiver from sender for signal.
|
|
117
135
|
|
|
@@ -147,11 +165,11 @@ class Signal:
|
|
|
147
165
|
self.sender_receivers_cache.clear()
|
|
148
166
|
return disconnected
|
|
149
167
|
|
|
150
|
-
def has_listeners(self, sender=None):
|
|
168
|
+
def has_listeners(self, sender: Any = None) -> bool:
|
|
151
169
|
sync_receivers = self._live_receivers(sender)
|
|
152
170
|
return bool(sync_receivers)
|
|
153
171
|
|
|
154
|
-
def send(self, sender, **named):
|
|
172
|
+
def send(self, sender: Any, **named: Any) -> list[tuple[Callable[..., Any], Any]]:
|
|
155
173
|
"""
|
|
156
174
|
Send signal from sender to all connected receivers.
|
|
157
175
|
|
|
@@ -175,7 +193,7 @@ class Signal:
|
|
|
175
193
|
"""
|
|
176
194
|
if (
|
|
177
195
|
not self.receivers
|
|
178
|
-
or self.sender_receivers_cache.get(sender) is
|
|
196
|
+
or self.sender_receivers_cache.get(sender) is _NO_RECEIVERS
|
|
179
197
|
):
|
|
180
198
|
return []
|
|
181
199
|
responses = []
|
|
@@ -185,15 +203,17 @@ class Signal:
|
|
|
185
203
|
responses.append((receiver, response))
|
|
186
204
|
return responses
|
|
187
205
|
|
|
188
|
-
def _log_robust_failure(self, receiver, err):
|
|
189
|
-
|
|
206
|
+
def _log_robust_failure(self, receiver: Callable[..., Any], err: Exception) -> None:
|
|
207
|
+
_logger.error(
|
|
190
208
|
"Error calling %s in Signal.send_robust() (%s)",
|
|
191
|
-
receiver
|
|
209
|
+
getattr(receiver, "__qualname__", repr(receiver)),
|
|
192
210
|
err,
|
|
193
211
|
exc_info=err,
|
|
194
212
|
)
|
|
195
213
|
|
|
196
|
-
def send_robust(
|
|
214
|
+
def send_robust(
|
|
215
|
+
self, sender: Any, **named: Any
|
|
216
|
+
) -> list[tuple[Callable[..., Any], Any]]:
|
|
197
217
|
"""
|
|
198
218
|
Send signal from sender to all connected receivers catching errors.
|
|
199
219
|
|
|
@@ -218,7 +238,7 @@ class Signal:
|
|
|
218
238
|
"""
|
|
219
239
|
if (
|
|
220
240
|
not self.receivers
|
|
221
|
-
or self.sender_receivers_cache.get(sender) is
|
|
241
|
+
or self.sender_receivers_cache.get(sender) is _NO_RECEIVERS
|
|
222
242
|
):
|
|
223
243
|
return []
|
|
224
244
|
|
|
@@ -236,7 +256,7 @@ class Signal:
|
|
|
236
256
|
responses.append((receiver, response))
|
|
237
257
|
return responses
|
|
238
258
|
|
|
239
|
-
def _clear_dead_receivers(self):
|
|
259
|
+
def _clear_dead_receivers(self) -> None:
|
|
240
260
|
# Note: caller is assumed to hold self.lock.
|
|
241
261
|
if self._dead_receivers:
|
|
242
262
|
self._dead_receivers = False
|
|
@@ -246,7 +266,7 @@ class Signal:
|
|
|
246
266
|
if not (isinstance(r[1], weakref.ReferenceType) and r[1]() is None)
|
|
247
267
|
]
|
|
248
268
|
|
|
249
|
-
def _live_receivers(self, sender):
|
|
269
|
+
def _live_receivers(self, sender: Any) -> list[Callable[..., Any]]:
|
|
250
270
|
"""
|
|
251
271
|
Filter sequence of receivers to get resolved, live receivers.
|
|
252
272
|
|
|
@@ -256,9 +276,9 @@ class Signal:
|
|
|
256
276
|
receivers = None
|
|
257
277
|
if self.use_caching and not self._dead_receivers:
|
|
258
278
|
receivers = self.sender_receivers_cache.get(sender)
|
|
259
|
-
# We could end up here with
|
|
279
|
+
# We could end up here with _NO_RECEIVERS even if we do check this case in
|
|
260
280
|
# .send() prior to calling _live_receivers() due to concurrent .send() call.
|
|
261
|
-
if receivers is
|
|
281
|
+
if receivers is _NO_RECEIVERS:
|
|
262
282
|
return []
|
|
263
283
|
if receivers is None:
|
|
264
284
|
with self.lock:
|
|
@@ -266,11 +286,11 @@ class Signal:
|
|
|
266
286
|
senderkey = _make_id(sender)
|
|
267
287
|
receivers = []
|
|
268
288
|
for (_receiverkey, r_senderkey), receiver in self.receivers:
|
|
269
|
-
if r_senderkey ==
|
|
289
|
+
if r_senderkey == _NONE_ID or r_senderkey == senderkey:
|
|
270
290
|
receivers.append(receiver)
|
|
271
291
|
if self.use_caching:
|
|
272
292
|
if not receivers:
|
|
273
|
-
self.sender_receivers_cache[sender] =
|
|
293
|
+
self.sender_receivers_cache[sender] = _NO_RECEIVERS
|
|
274
294
|
else:
|
|
275
295
|
# Note, we must cache the weakref versions.
|
|
276
296
|
self.sender_receivers_cache[sender] = receivers
|
|
@@ -285,7 +305,7 @@ class Signal:
|
|
|
285
305
|
non_weak_sync_receivers.append(receiver)
|
|
286
306
|
return non_weak_sync_receivers
|
|
287
307
|
|
|
288
|
-
def _remove_receiver(self, receiver=None):
|
|
308
|
+
def _remove_receiver(self, receiver: Any = None) -> None:
|
|
289
309
|
# Mark that the self.receivers list has dead weakrefs. If so, we will
|
|
290
310
|
# clean those up in connect, disconnect and _live_receivers while
|
|
291
311
|
# holding self.lock. Note that doing the cleanup here isn't a good
|
|
@@ -295,7 +315,9 @@ class Signal:
|
|
|
295
315
|
self._dead_receivers = True
|
|
296
316
|
|
|
297
317
|
|
|
298
|
-
def receiver(
|
|
318
|
+
def receiver(
|
|
319
|
+
signal: Signal | Sequence[Signal], **kwargs: Any
|
|
320
|
+
) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
|
|
299
321
|
"""
|
|
300
322
|
A decorator for connecting receivers to signals. Used by passing in the
|
|
301
323
|
signal (or list of signals) and keyword arguments to connect::
|
|
@@ -309,12 +331,12 @@ def receiver(signal, **kwargs):
|
|
|
309
331
|
...
|
|
310
332
|
"""
|
|
311
333
|
|
|
312
|
-
def _decorator(func):
|
|
313
|
-
if isinstance(signal,
|
|
334
|
+
def _decorator(func: Callable[..., Any]) -> Callable[..., Any]:
|
|
335
|
+
if isinstance(signal, Signal):
|
|
336
|
+
signal.connect(func, **kwargs)
|
|
337
|
+
else:
|
|
314
338
|
for s in signal:
|
|
315
339
|
s.connect(func, **kwargs)
|
|
316
|
-
else:
|
|
317
|
-
signal.connect(func, **kwargs)
|
|
318
340
|
return func
|
|
319
341
|
|
|
320
342
|
return _decorator
|
plain/signing.py
CHANGED
|
@@ -33,12 +33,15 @@ There are 65 url-safe characters: the 64 used by url-safe base64 and the ':'.
|
|
|
33
33
|
These functions make use of all of them.
|
|
34
34
|
"""
|
|
35
35
|
|
|
36
|
+
from __future__ import annotations
|
|
37
|
+
|
|
36
38
|
import base64
|
|
37
39
|
import datetime
|
|
38
40
|
import hmac
|
|
39
41
|
import json
|
|
40
42
|
import time
|
|
41
43
|
import zlib
|
|
44
|
+
from typing import Any
|
|
42
45
|
|
|
43
46
|
from plain.runtime import settings
|
|
44
47
|
from plain.utils.crypto import salted_hmac
|
|
@@ -61,7 +64,7 @@ class SignatureExpired(BadSignature):
|
|
|
61
64
|
pass
|
|
62
65
|
|
|
63
66
|
|
|
64
|
-
def b62_encode(s):
|
|
67
|
+
def b62_encode(s: int) -> str:
|
|
65
68
|
if s == 0:
|
|
66
69
|
return "0"
|
|
67
70
|
sign = "-" if s < 0 else ""
|
|
@@ -73,7 +76,7 @@ def b62_encode(s):
|
|
|
73
76
|
return sign + encoded
|
|
74
77
|
|
|
75
78
|
|
|
76
|
-
def b62_decode(s):
|
|
79
|
+
def b62_decode(s: str) -> int:
|
|
77
80
|
if s == "0":
|
|
78
81
|
return 0
|
|
79
82
|
sign = 1
|
|
@@ -86,16 +89,16 @@ def b62_decode(s):
|
|
|
86
89
|
return sign * decoded
|
|
87
90
|
|
|
88
91
|
|
|
89
|
-
def b64_encode(s):
|
|
92
|
+
def b64_encode(s: bytes) -> bytes:
|
|
90
93
|
return base64.urlsafe_b64encode(s).strip(b"=")
|
|
91
94
|
|
|
92
95
|
|
|
93
|
-
def b64_decode(s):
|
|
96
|
+
def b64_decode(s: bytes) -> bytes:
|
|
94
97
|
pad = b"=" * (-len(s) % 4)
|
|
95
98
|
return base64.urlsafe_b64decode(s + pad)
|
|
96
99
|
|
|
97
100
|
|
|
98
|
-
def base64_hmac(salt, value, key, algorithm="sha1"):
|
|
101
|
+
def base64_hmac(salt: str, value: str, key: str, algorithm: str = "sha1") -> str:
|
|
99
102
|
return b64_encode(
|
|
100
103
|
salted_hmac(salt, value, key, algorithm=algorithm).digest()
|
|
101
104
|
).decode()
|
|
@@ -107,16 +110,20 @@ class JSONSerializer:
|
|
|
107
110
|
signing.loads.
|
|
108
111
|
"""
|
|
109
112
|
|
|
110
|
-
def dumps(self, obj):
|
|
113
|
+
def dumps(self, obj: Any) -> bytes:
|
|
111
114
|
return json.dumps(obj, separators=(",", ":")).encode("latin-1")
|
|
112
115
|
|
|
113
|
-
def loads(self, data):
|
|
116
|
+
def loads(self, data: bytes) -> Any:
|
|
114
117
|
return json.loads(data.decode("latin-1"))
|
|
115
118
|
|
|
116
119
|
|
|
117
120
|
def dumps(
|
|
118
|
-
obj
|
|
119
|
-
|
|
121
|
+
obj: Any,
|
|
122
|
+
key: str | None = None,
|
|
123
|
+
salt: str = "plain.signing",
|
|
124
|
+
serializer: type[JSONSerializer] = JSONSerializer,
|
|
125
|
+
compress: bool = False,
|
|
126
|
+
) -> str:
|
|
120
127
|
"""
|
|
121
128
|
Return URL-safe, hmac signed base64 compressed JSON string. If key is
|
|
122
129
|
None, use settings.SECRET_KEY instead. The hmac algorithm is the default
|
|
@@ -139,13 +146,13 @@ def dumps(
|
|
|
139
146
|
|
|
140
147
|
|
|
141
148
|
def loads(
|
|
142
|
-
s,
|
|
143
|
-
key=None,
|
|
144
|
-
salt="plain.signing",
|
|
145
|
-
serializer=JSONSerializer,
|
|
146
|
-
max_age=None,
|
|
147
|
-
fallback_keys=None,
|
|
148
|
-
):
|
|
149
|
+
s: str,
|
|
150
|
+
key: str | None = None,
|
|
151
|
+
salt: str = "plain.signing",
|
|
152
|
+
serializer: type[JSONSerializer] = JSONSerializer,
|
|
153
|
+
max_age: int | float | datetime.timedelta | None = None,
|
|
154
|
+
fallback_keys: list[str] | None = None,
|
|
155
|
+
) -> Any:
|
|
149
156
|
"""
|
|
150
157
|
Reverse of dumps(), raise BadSignature if signature fails.
|
|
151
158
|
|
|
@@ -164,12 +171,12 @@ class Signer:
|
|
|
164
171
|
def __init__(
|
|
165
172
|
self,
|
|
166
173
|
*,
|
|
167
|
-
key=None,
|
|
168
|
-
sep=":",
|
|
169
|
-
salt=None,
|
|
170
|
-
algorithm="sha256",
|
|
171
|
-
fallback_keys=None,
|
|
172
|
-
):
|
|
174
|
+
key: str | None = None,
|
|
175
|
+
sep: str = ":",
|
|
176
|
+
salt: str | None = None,
|
|
177
|
+
algorithm: str = "sha256",
|
|
178
|
+
fallback_keys: list[str] | None = None,
|
|
179
|
+
) -> None:
|
|
173
180
|
self.key = key or settings.SECRET_KEY
|
|
174
181
|
self.fallback_keys = (
|
|
175
182
|
fallback_keys
|
|
@@ -186,14 +193,14 @@ class Signer:
|
|
|
186
193
|
"only A-z0-9-_=)",
|
|
187
194
|
)
|
|
188
195
|
|
|
189
|
-
def signature(self, value, key=None):
|
|
196
|
+
def signature(self, value: str, key: str | None = None) -> str:
|
|
190
197
|
key = key or self.key
|
|
191
198
|
return base64_hmac(self.salt + "signer", value, key, algorithm=self.algorithm)
|
|
192
199
|
|
|
193
|
-
def sign(self, value):
|
|
200
|
+
def sign(self, value: str) -> str:
|
|
194
201
|
return f"{value}{self.sep}{self.signature(value)}"
|
|
195
202
|
|
|
196
|
-
def unsign(self, signed_value):
|
|
203
|
+
def unsign(self, signed_value: str) -> str:
|
|
197
204
|
if self.sep not in signed_value:
|
|
198
205
|
raise BadSignature(f'No "{self.sep}" found in value')
|
|
199
206
|
value, sig = signed_value.rsplit(self.sep, 1)
|
|
@@ -204,7 +211,12 @@ class Signer:
|
|
|
204
211
|
return value
|
|
205
212
|
raise BadSignature(f'Signature "{sig}" does not match')
|
|
206
213
|
|
|
207
|
-
def sign_object(
|
|
214
|
+
def sign_object(
|
|
215
|
+
self,
|
|
216
|
+
obj: Any,
|
|
217
|
+
serializer: type[JSONSerializer] = JSONSerializer,
|
|
218
|
+
compress: bool = False,
|
|
219
|
+
) -> str:
|
|
208
220
|
"""
|
|
209
221
|
Return URL-safe, hmac signed base64 compressed JSON string.
|
|
210
222
|
|
|
@@ -229,7 +241,12 @@ class Signer:
|
|
|
229
241
|
base64d = "." + base64d
|
|
230
242
|
return self.sign(base64d)
|
|
231
243
|
|
|
232
|
-
def unsign_object(
|
|
244
|
+
def unsign_object(
|
|
245
|
+
self,
|
|
246
|
+
signed_obj: str,
|
|
247
|
+
serializer: type[JSONSerializer] = JSONSerializer,
|
|
248
|
+
**kwargs: Any,
|
|
249
|
+
) -> Any:
|
|
233
250
|
# Signer.unsign() returns str but base64 and zlib compression operate
|
|
234
251
|
# on bytes.
|
|
235
252
|
base64d = self.unsign(signed_obj, **kwargs).encode()
|
|
@@ -243,27 +260,106 @@ class Signer:
|
|
|
243
260
|
return serializer().loads(data)
|
|
244
261
|
|
|
245
262
|
|
|
246
|
-
class TimestampSigner
|
|
247
|
-
|
|
263
|
+
class TimestampSigner:
|
|
264
|
+
"""A signer that includes a timestamp for max_age validation.
|
|
265
|
+
|
|
266
|
+
Uses composition rather than inheritance since the interface
|
|
267
|
+
intentionally differs from Signer (unsign accepts max_age parameter).
|
|
268
|
+
"""
|
|
269
|
+
|
|
270
|
+
def __init__(
|
|
271
|
+
self,
|
|
272
|
+
*,
|
|
273
|
+
key: str | None = None,
|
|
274
|
+
sep: str = ":",
|
|
275
|
+
salt: str | None = None,
|
|
276
|
+
algorithm: str = "sha256",
|
|
277
|
+
fallback_keys: list[str] | None = None,
|
|
278
|
+
) -> None:
|
|
279
|
+
# Compute default salt here to preserve backwards compatibility.
|
|
280
|
+
# When TimestampSigner inherited from Signer, the default salt was
|
|
281
|
+
# "plain.signing.TimestampSigner". Now that we use composition,
|
|
282
|
+
# we must set it explicitly rather than letting Signer compute its own.
|
|
283
|
+
if salt is None:
|
|
284
|
+
salt = f"{self.__class__.__module__}.{self.__class__.__name__}"
|
|
285
|
+
self._signer = Signer(
|
|
286
|
+
key=key,
|
|
287
|
+
sep=sep,
|
|
288
|
+
salt=salt,
|
|
289
|
+
algorithm=algorithm,
|
|
290
|
+
fallback_keys=fallback_keys,
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
@property
|
|
294
|
+
def sep(self) -> str:
|
|
295
|
+
return self._signer.sep
|
|
296
|
+
|
|
297
|
+
def timestamp(self) -> str:
|
|
248
298
|
return b62_encode(int(time.time()))
|
|
249
299
|
|
|
250
|
-
def sign(self, value):
|
|
300
|
+
def sign(self, value: str) -> str:
|
|
251
301
|
value = f"{value}{self.sep}{self.timestamp()}"
|
|
252
|
-
return
|
|
302
|
+
return self._signer.sign(value)
|
|
253
303
|
|
|
254
|
-
def unsign(
|
|
304
|
+
def unsign(
|
|
305
|
+
self, value: str, max_age: int | float | datetime.timedelta | None = None
|
|
306
|
+
) -> str:
|
|
255
307
|
"""
|
|
256
308
|
Retrieve original value and check it wasn't signed more
|
|
257
309
|
than max_age seconds ago.
|
|
258
310
|
"""
|
|
259
|
-
result =
|
|
311
|
+
result = self._signer.unsign(value)
|
|
260
312
|
value, timestamp = result.rsplit(self.sep, 1)
|
|
261
|
-
|
|
313
|
+
ts = b62_decode(timestamp)
|
|
262
314
|
if max_age is not None:
|
|
263
315
|
if isinstance(max_age, datetime.timedelta):
|
|
264
316
|
max_age = max_age.total_seconds()
|
|
265
317
|
# Check timestamp is not older than max_age
|
|
266
|
-
age = time.time() -
|
|
318
|
+
age = time.time() - ts
|
|
267
319
|
if age > max_age:
|
|
268
320
|
raise SignatureExpired(f"Signature age {age} > {max_age} seconds")
|
|
269
321
|
return value
|
|
322
|
+
|
|
323
|
+
def sign_object(
|
|
324
|
+
self,
|
|
325
|
+
obj: Any,
|
|
326
|
+
serializer: type[JSONSerializer] = JSONSerializer,
|
|
327
|
+
compress: bool = False,
|
|
328
|
+
) -> str:
|
|
329
|
+
"""
|
|
330
|
+
Return URL-safe, hmac signed base64 compressed JSON string.
|
|
331
|
+
|
|
332
|
+
If compress is True (not the default), check if compressing using zlib
|
|
333
|
+
can save some space. Prepend a '.' to signify compression. This is
|
|
334
|
+
included in the signature, to protect against zip bombs.
|
|
335
|
+
|
|
336
|
+
The serializer is expected to return a bytestring.
|
|
337
|
+
"""
|
|
338
|
+
data = serializer().dumps(obj)
|
|
339
|
+
is_compressed = False
|
|
340
|
+
|
|
341
|
+
if compress:
|
|
342
|
+
compressed = zlib.compress(data)
|
|
343
|
+
if len(compressed) < (len(data) - 1):
|
|
344
|
+
data = compressed
|
|
345
|
+
is_compressed = True
|
|
346
|
+
base64d = b64_encode(data).decode()
|
|
347
|
+
if is_compressed:
|
|
348
|
+
base64d = "." + base64d
|
|
349
|
+
return self.sign(base64d)
|
|
350
|
+
|
|
351
|
+
def unsign_object(
|
|
352
|
+
self,
|
|
353
|
+
signed_obj: str,
|
|
354
|
+
serializer: type[JSONSerializer] = JSONSerializer,
|
|
355
|
+
max_age: int | float | datetime.timedelta | None = None,
|
|
356
|
+
) -> Any:
|
|
357
|
+
"""Unsign and decode an object, optionally checking max_age."""
|
|
358
|
+
base64d = self.unsign(signed_obj, max_age=max_age).encode()
|
|
359
|
+
decompress = base64d[:1] == b"."
|
|
360
|
+
if decompress:
|
|
361
|
+
base64d = base64d[1:]
|
|
362
|
+
data = b64_decode(base64d)
|
|
363
|
+
if decompress:
|
|
364
|
+
data = zlib.decompress(data)
|
|
365
|
+
return serializer().loads(data)
|