plain 0.68.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 +656 -1
- 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 -36
- 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 +110 -26
- 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 +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/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 +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.101.2.dist-info}/METADATA +4 -2
- plain-0.101.2.dist-info/RECORD +201 -0
- {plain-0.68.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/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.101.2.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)
|
plain/skills/README.md
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# plain.skills
|
|
2
|
+
|
|
3
|
+
**Agent skills for working with Plain projects.**
|
|
4
|
+
|
|
5
|
+
These skills provide context and workflows for common tasks when using [Claude Code](https://docs.anthropic.com/en/docs/claude-code) or [Codex](https://codex.openai.com/).
|
|
6
|
+
|
|
7
|
+
## Available skills
|
|
8
|
+
|
|
9
|
+
| Skill | Description |
|
|
10
|
+
| --------------- | -------------------------------------------------------------- |
|
|
11
|
+
| `plain-docs` | Retrieves detailed documentation for Plain packages |
|
|
12
|
+
| `plain-install` | Installs Plain packages and guides through setup steps |
|
|
13
|
+
| `plain-upgrade` | Upgrades Plain packages and applies required migration changes |
|
|
14
|
+
| `plain-shell` | Runs Python with Plain configured and database access |
|
|
15
|
+
| `plain-request` | Makes test HTTP requests against the development database |
|
|
16
|
+
|
|
17
|
+
## Installation
|
|
18
|
+
|
|
19
|
+
To install skills to your project's `.claude/` or `.codex/` directory:
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
uv run plain agent install
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
This command:
|
|
26
|
+
|
|
27
|
+
- Copies skill definitions so your agent can use them
|
|
28
|
+
- Sets up a `SessionStart` hook that runs `plain agent context` at the start of every session
|
|
29
|
+
|
|
30
|
+
Run it again after upgrading Plain to get updated skills.
|
|
31
|
+
|
|
32
|
+
## Commands
|
|
33
|
+
|
|
34
|
+
- `plain agent install` - Install skills and set up hooks
|
|
35
|
+
- `plain agent skills` - List available skills from installed packages
|
|
36
|
+
- `plain agent context` - Output framework context (used by the SessionStart hook)
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: plain-docs
|
|
3
|
+
description: Retrieves detailed documentation for Plain packages. Use when looking up package APIs or feature details.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Getting Documentation
|
|
7
|
+
|
|
8
|
+
## List Available Packages
|
|
9
|
+
|
|
10
|
+
```
|
|
11
|
+
uv run plain docs --list
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## Get Package Documentation
|
|
15
|
+
|
|
16
|
+
```
|
|
17
|
+
uv run plain docs <package> --source
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Examples:
|
|
21
|
+
|
|
22
|
+
- `uv run plain docs models --source` - Models and database
|
|
23
|
+
- `uv run plain docs templates --source` - Jinja2 templates
|
|
24
|
+
- `uv run plain docs assets --source` - Static assets
|
|
25
|
+
- `uv run plain docs tailwind --source` - Tailwind CSS integration
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: plain-install
|
|
3
|
+
description: Installs Plain packages and guides through setup steps. Use when adding new packages to a project.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Install Plain Packages
|
|
7
|
+
|
|
8
|
+
## 1. Install the package(s)
|
|
9
|
+
|
|
10
|
+
```
|
|
11
|
+
uv run plain install <package-name> [additional-packages...]
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## 2. Complete setup for each package
|
|
15
|
+
|
|
16
|
+
1. Run `uv run plain docs <package>` and read the installation instructions
|
|
17
|
+
2. If the docs indicate it's a dev tool, move it: `uv remove <package> && uv add <package> --dev`
|
|
18
|
+
3. Complete any code modifications from the installation instructions
|
|
19
|
+
|
|
20
|
+
## Guidelines
|
|
21
|
+
|
|
22
|
+
- DO NOT commit any changes
|
|
23
|
+
- Report back with:
|
|
24
|
+
- Whether setup completed successfully
|
|
25
|
+
- Any manual steps the user needs to complete
|
|
26
|
+
- Any issues or errors encountered
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: plain-request
|
|
3
|
+
description: Makes HTTP requests to test URLs, check endpoints, fetch pages, or debug routes. Use when asked to look at a URL, hit an endpoint, test a route, or make GET/POST requests.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Making HTTP Requests
|
|
7
|
+
|
|
8
|
+
Use `uv run plain request` to make test requests against the dev database.
|
|
9
|
+
|
|
10
|
+
## Basic Usage
|
|
11
|
+
|
|
12
|
+
```
|
|
13
|
+
uv run plain request /path
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## With Authentication
|
|
17
|
+
|
|
18
|
+
```
|
|
19
|
+
uv run plain request /path --user 1
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## With Custom Headers
|
|
23
|
+
|
|
24
|
+
```
|
|
25
|
+
uv run plain request /path --header "Accept: application/json"
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## POST/PUT/PATCH with Data
|
|
29
|
+
|
|
30
|
+
```
|
|
31
|
+
uv run plain request /path --method POST --data '{"key": "value"}'
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Limiting Output
|
|
35
|
+
|
|
36
|
+
```
|
|
37
|
+
uv run plain request /path --no-body # Headers only
|
|
38
|
+
uv run plain request /path --no-headers # Body only
|
|
39
|
+
```
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: plain-shell
|
|
3
|
+
description: Runs Python with Plain configured and database access. Use for scripts, one-off commands, or interactive sessions.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Running Python with Plain
|
|
7
|
+
|
|
8
|
+
## Interactive Shell
|
|
9
|
+
|
|
10
|
+
```
|
|
11
|
+
uv run plain shell
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## One-off Command
|
|
15
|
+
|
|
16
|
+
```
|
|
17
|
+
uv run plain shell -c "from app.users.models import User; print(User.query.count())"
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Run a Script
|
|
21
|
+
|
|
22
|
+
```
|
|
23
|
+
uv run plain run script.py
|
|
24
|
+
```
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: plain-upgrade
|
|
3
|
+
description: Upgrades Plain packages and applies required migration changes. Use when updating to newer package versions.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Upgrade Plain Packages
|
|
7
|
+
|
|
8
|
+
## 1. Run the upgrade
|
|
9
|
+
|
|
10
|
+
```
|
|
11
|
+
uv run plain upgrade [package-names...]
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
This will show which packages were upgraded (e.g., `plain-models: 0.1.0 -> 0.2.0`).
|
|
15
|
+
|
|
16
|
+
## 2. Apply code changes for each upgraded package
|
|
17
|
+
|
|
18
|
+
For each package that was upgraded:
|
|
19
|
+
|
|
20
|
+
1. Run `uv run plain changelog <package> --from <old-version> --to <new-version>`
|
|
21
|
+
2. Read the "Upgrade instructions" section
|
|
22
|
+
3. If it says "No changes required", skip to next package
|
|
23
|
+
4. Apply any required code changes
|
|
24
|
+
|
|
25
|
+
## 3. Validate
|
|
26
|
+
|
|
27
|
+
1. Run `uv run plain fix` to fix formatting
|
|
28
|
+
2. Run `uv run plain preflight` to validate configuration
|
|
29
|
+
|
|
30
|
+
## Guidelines
|
|
31
|
+
|
|
32
|
+
- Process ALL packages before testing
|
|
33
|
+
- DO NOT commit any changes
|
|
34
|
+
- Keep code changes minimal and focused
|
|
35
|
+
- Report any issues or conflicts encountered
|