plain 0.66.0__py3-none-any.whl → 0.101.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (197) hide show
  1. plain/CHANGELOG.md +684 -0
  2. plain/README.md +1 -1
  3. plain/assets/compile.py +25 -12
  4. plain/assets/finders.py +24 -17
  5. plain/assets/fingerprints.py +10 -7
  6. plain/assets/urls.py +1 -1
  7. plain/assets/views.py +47 -33
  8. plain/chores/README.md +25 -23
  9. plain/chores/__init__.py +2 -1
  10. plain/chores/core.py +27 -0
  11. plain/chores/registry.py +23 -53
  12. plain/cli/README.md +185 -16
  13. plain/cli/__init__.py +2 -1
  14. plain/cli/agent.py +236 -0
  15. plain/cli/build.py +7 -8
  16. plain/cli/changelog.py +11 -5
  17. plain/cli/chores.py +32 -34
  18. plain/cli/core.py +112 -28
  19. plain/cli/docs.py +52 -11
  20. plain/cli/formatting.py +40 -17
  21. plain/cli/install.py +10 -54
  22. plain/cli/{agent/llmdocs.py → llmdocs.py} +21 -9
  23. plain/cli/output.py +6 -2
  24. plain/cli/preflight.py +175 -102
  25. plain/cli/print.py +4 -4
  26. plain/cli/registry.py +95 -26
  27. plain/cli/request.py +206 -0
  28. plain/cli/runtime.py +45 -0
  29. plain/cli/scaffold.py +2 -7
  30. plain/cli/server.py +153 -0
  31. plain/cli/settings.py +53 -49
  32. plain/cli/shell.py +15 -12
  33. plain/cli/startup.py +9 -8
  34. plain/cli/upgrade.py +17 -104
  35. plain/cli/urls.py +12 -7
  36. plain/cli/utils.py +3 -3
  37. plain/csrf/README.md +65 -40
  38. plain/csrf/middleware.py +53 -43
  39. plain/debug.py +5 -2
  40. plain/exceptions.py +22 -114
  41. plain/forms/README.md +453 -24
  42. plain/forms/__init__.py +55 -4
  43. plain/forms/boundfield.py +15 -8
  44. plain/forms/exceptions.py +1 -1
  45. plain/forms/fields.py +346 -143
  46. plain/forms/forms.py +75 -45
  47. plain/http/README.md +356 -9
  48. plain/http/__init__.py +41 -26
  49. plain/http/cookie.py +15 -7
  50. plain/http/exceptions.py +65 -0
  51. plain/http/middleware.py +32 -0
  52. plain/http/multipartparser.py +99 -88
  53. plain/http/request.py +362 -250
  54. plain/http/response.py +99 -197
  55. plain/internal/__init__.py +8 -1
  56. plain/internal/files/base.py +35 -19
  57. plain/internal/files/locks.py +19 -11
  58. plain/internal/files/move.py +8 -3
  59. plain/internal/files/temp.py +25 -6
  60. plain/internal/files/uploadedfile.py +47 -28
  61. plain/internal/files/uploadhandler.py +64 -58
  62. plain/internal/files/utils.py +24 -10
  63. plain/internal/handlers/base.py +34 -23
  64. plain/internal/handlers/exception.py +68 -65
  65. plain/internal/handlers/wsgi.py +65 -54
  66. plain/internal/middleware/headers.py +37 -11
  67. plain/internal/middleware/hosts.py +11 -13
  68. plain/internal/middleware/https.py +17 -7
  69. plain/internal/middleware/slash.py +14 -9
  70. plain/internal/reloader.py +77 -0
  71. plain/json.py +2 -1
  72. plain/logs/README.md +161 -62
  73. plain/logs/__init__.py +1 -1
  74. plain/logs/{loggers.py → app.py} +71 -67
  75. plain/logs/configure.py +63 -14
  76. plain/logs/debug.py +17 -6
  77. plain/logs/filters.py +15 -0
  78. plain/logs/formatters.py +7 -4
  79. plain/packages/README.md +105 -23
  80. plain/packages/config.py +15 -7
  81. plain/packages/registry.py +40 -15
  82. plain/paginator.py +31 -21
  83. plain/preflight/README.md +208 -23
  84. plain/preflight/__init__.py +5 -24
  85. plain/preflight/checks.py +12 -0
  86. plain/preflight/files.py +19 -13
  87. plain/preflight/registry.py +80 -58
  88. plain/preflight/results.py +37 -0
  89. plain/preflight/security.py +65 -71
  90. plain/preflight/settings.py +54 -0
  91. plain/preflight/urls.py +10 -48
  92. plain/runtime/README.md +115 -47
  93. plain/runtime/__init__.py +10 -6
  94. plain/runtime/global_settings.py +43 -33
  95. plain/runtime/secret.py +20 -0
  96. plain/runtime/user_settings.py +110 -38
  97. plain/runtime/utils.py +1 -1
  98. plain/server/LICENSE +35 -0
  99. plain/server/README.md +155 -0
  100. plain/server/__init__.py +9 -0
  101. plain/server/app.py +52 -0
  102. plain/server/arbiter.py +555 -0
  103. plain/server/config.py +118 -0
  104. plain/server/errors.py +31 -0
  105. plain/server/glogging.py +292 -0
  106. plain/server/http/__init__.py +12 -0
  107. plain/server/http/body.py +283 -0
  108. plain/server/http/errors.py +155 -0
  109. plain/server/http/message.py +400 -0
  110. plain/server/http/parser.py +70 -0
  111. plain/server/http/unreader.py +88 -0
  112. plain/server/http/wsgi.py +421 -0
  113. plain/server/pidfile.py +92 -0
  114. plain/server/sock.py +240 -0
  115. plain/server/util.py +317 -0
  116. plain/server/workers/__init__.py +6 -0
  117. plain/server/workers/base.py +304 -0
  118. plain/server/workers/sync.py +212 -0
  119. plain/server/workers/thread.py +399 -0
  120. plain/server/workers/workertmp.py +50 -0
  121. plain/signals/README.md +170 -1
  122. plain/signals/__init__.py +0 -1
  123. plain/signals/dispatch/dispatcher.py +49 -27
  124. plain/signing.py +131 -35
  125. plain/skills/README.md +36 -0
  126. plain/skills/plain-docs/SKILL.md +25 -0
  127. plain/skills/plain-install/SKILL.md +26 -0
  128. plain/skills/plain-request/SKILL.md +39 -0
  129. plain/skills/plain-shell/SKILL.md +24 -0
  130. plain/skills/plain-upgrade/SKILL.md +35 -0
  131. plain/templates/README.md +211 -20
  132. plain/templates/jinja/__init__.py +14 -27
  133. plain/templates/jinja/environments.py +5 -4
  134. plain/templates/jinja/extensions.py +12 -5
  135. plain/templates/jinja/filters.py +7 -2
  136. plain/templates/jinja/globals.py +2 -2
  137. plain/test/README.md +184 -22
  138. plain/test/client.py +340 -222
  139. plain/test/encoding.py +9 -6
  140. plain/test/exceptions.py +7 -2
  141. plain/urls/README.md +157 -73
  142. plain/urls/converters.py +18 -15
  143. plain/urls/exceptions.py +2 -2
  144. plain/urls/patterns.py +56 -40
  145. plain/urls/resolvers.py +38 -28
  146. plain/urls/utils.py +5 -1
  147. plain/utils/README.md +250 -3
  148. plain/utils/cache.py +17 -11
  149. plain/utils/crypto.py +21 -5
  150. plain/utils/datastructures.py +89 -56
  151. plain/utils/dateparse.py +9 -6
  152. plain/utils/deconstruct.py +15 -7
  153. plain/utils/decorators.py +5 -1
  154. plain/utils/dotenv.py +373 -0
  155. plain/utils/duration.py +8 -4
  156. plain/utils/encoding.py +14 -7
  157. plain/utils/functional.py +66 -49
  158. plain/utils/hashable.py +5 -1
  159. plain/utils/html.py +36 -22
  160. plain/utils/http.py +16 -9
  161. plain/utils/inspect.py +14 -6
  162. plain/utils/ipv6.py +7 -3
  163. plain/utils/itercompat.py +6 -1
  164. plain/utils/module_loading.py +7 -3
  165. plain/utils/regex_helper.py +37 -23
  166. plain/utils/safestring.py +14 -6
  167. plain/utils/text.py +41 -23
  168. plain/utils/timezone.py +33 -22
  169. plain/utils/tree.py +35 -19
  170. plain/validators.py +94 -52
  171. plain/views/README.md +156 -79
  172. plain/views/__init__.py +0 -1
  173. plain/views/base.py +25 -18
  174. plain/views/errors.py +13 -5
  175. plain/views/exceptions.py +4 -1
  176. plain/views/forms.py +6 -6
  177. plain/views/objects.py +52 -49
  178. plain/views/redirect.py +18 -15
  179. plain/views/templates.py +5 -3
  180. plain/wsgi.py +3 -1
  181. {plain-0.66.0.dist-info → plain-0.101.2.dist-info}/METADATA +4 -2
  182. plain-0.101.2.dist-info/RECORD +201 -0
  183. {plain-0.66.0.dist-info → plain-0.101.2.dist-info}/WHEEL +1 -1
  184. plain-0.101.2.dist-info/entry_points.txt +2 -0
  185. plain/AGENTS.md +0 -18
  186. plain/cli/agent/__init__.py +0 -20
  187. plain/cli/agent/docs.py +0 -80
  188. plain/cli/agent/md.py +0 -87
  189. plain/cli/agent/prompt.py +0 -45
  190. plain/cli/agent/request.py +0 -181
  191. plain/csrf/views.py +0 -31
  192. plain/logs/utils.py +0 -46
  193. plain/preflight/messages.py +0 -81
  194. plain/templates/AGENTS.md +0 -3
  195. plain-0.66.0.dist-info/RECORD +0 -168
  196. plain-0.66.0.dist-info/entry_points.txt +0 -4
  197. {plain-0.66.0.dist-info → plain-0.101.2.dist-info}/licenses/LICENSE +0 -0
@@ -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
- logger = logging.getLogger("plain.signals.dispatch")
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
- NONE_ID = _make_id(None)
23
+ _NONE_ID = _make_id(None)
17
24
 
18
25
  # A marker for caching
19
- NO_RECEIVERS = object()
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(self, receiver, sender=None, weak=True, dispatch_uid=None):
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(self, receiver=None, sender=None, dispatch_uid=None):
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 NO_RECEIVERS
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
- logger.error(
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.__qualname__,
209
+ getattr(receiver, "__qualname__", repr(receiver)),
192
210
  err,
193
211
  exc_info=err,
194
212
  )
195
213
 
196
- def send_robust(self, sender, **named):
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 NO_RECEIVERS
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 NO_RECEIVERS even if we do check this case in
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 NO_RECEIVERS:
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 == NONE_ID or r_senderkey == 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] = NO_RECEIVERS
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(signal, **kwargs):
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, list | tuple):
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, key=None, salt="plain.signing", serializer=JSONSerializer, compress=False
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(self, obj, serializer=JSONSerializer, compress=False):
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(self, signed_obj, serializer=JSONSerializer, **kwargs):
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(Signer):
247
- def timestamp(self):
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 super().sign(value)
302
+ return self._signer.sign(value)
253
303
 
254
- def unsign(self, value, max_age=None):
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 = super().unsign(value)
311
+ result = self._signer.unsign(value)
260
312
  value, timestamp = result.rsplit(self.sep, 1)
261
- timestamp = b62_decode(timestamp)
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() - timestamp
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