plain 0.69.0__py3-none-any.whl → 0.70.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.
Files changed (126) hide show
  1. plain/AGENTS.md +1 -1
  2. plain/CHANGELOG.md +11 -0
  3. plain/assets/compile.py +20 -7
  4. plain/assets/finders.py +15 -11
  5. plain/assets/fingerprints.py +6 -5
  6. plain/assets/urls.py +1 -1
  7. plain/assets/views.py +23 -17
  8. plain/chores/registry.py +14 -9
  9. plain/cli/agent/__init__.py +1 -1
  10. plain/cli/agent/docs.py +7 -6
  11. plain/cli/agent/llmdocs.py +18 -8
  12. plain/cli/agent/md.py +19 -14
  13. plain/cli/agent/prompt.py +1 -1
  14. plain/cli/agent/request.py +37 -17
  15. plain/cli/build.py +2 -2
  16. plain/cli/changelog.py +8 -4
  17. plain/cli/chores.py +4 -4
  18. plain/cli/core.py +8 -5
  19. plain/cli/docs.py +2 -2
  20. plain/cli/formatting.py +10 -7
  21. plain/cli/output.py +6 -2
  22. plain/cli/preflight.py +3 -3
  23. plain/cli/print.py +1 -1
  24. plain/cli/registry.py +10 -6
  25. plain/cli/scaffold.py +1 -1
  26. plain/cli/settings.py +1 -1
  27. plain/cli/shell.py +10 -7
  28. plain/cli/startup.py +3 -3
  29. plain/cli/urls.py +10 -4
  30. plain/cli/utils.py +2 -2
  31. plain/csrf/middleware.py +15 -5
  32. plain/csrf/views.py +11 -8
  33. plain/debug.py +5 -2
  34. plain/exceptions.py +19 -8
  35. plain/forms/__init__.py +1 -1
  36. plain/forms/boundfield.py +14 -7
  37. plain/forms/exceptions.py +1 -1
  38. plain/forms/fields.py +139 -97
  39. plain/forms/forms.py +55 -39
  40. plain/http/cookie.py +15 -7
  41. plain/http/multipartparser.py +50 -30
  42. plain/http/request.py +97 -73
  43. plain/http/response.py +99 -80
  44. plain/internal/__init__.py +8 -1
  45. plain/internal/files/base.py +34 -18
  46. plain/internal/files/locks.py +19 -11
  47. plain/internal/files/move.py +8 -3
  48. plain/internal/files/temp.py +23 -5
  49. plain/internal/files/uploadedfile.py +42 -26
  50. plain/internal/files/uploadhandler.py +48 -27
  51. plain/internal/files/utils.py +13 -6
  52. plain/internal/handlers/base.py +20 -6
  53. plain/internal/handlers/exception.py +19 -5
  54. plain/internal/handlers/wsgi.py +30 -18
  55. plain/internal/middleware/headers.py +11 -2
  56. plain/internal/middleware/hosts.py +10 -2
  57. plain/internal/middleware/https.py +13 -3
  58. plain/internal/middleware/slash.py +15 -5
  59. plain/json.py +2 -1
  60. plain/logs/configure.py +3 -1
  61. plain/logs/debug.py +16 -5
  62. plain/logs/formatters.py +6 -3
  63. plain/logs/loggers.py +56 -52
  64. plain/logs/utils.py +19 -9
  65. plain/packages/config.py +14 -6
  66. plain/packages/registry.py +27 -12
  67. plain/paginator.py +31 -21
  68. plain/preflight/checks.py +3 -1
  69. plain/preflight/files.py +3 -1
  70. plain/preflight/registry.py +25 -10
  71. plain/preflight/results.py +10 -4
  72. plain/preflight/security.py +7 -5
  73. plain/preflight/urls.py +4 -1
  74. plain/runtime/__init__.py +4 -3
  75. plain/runtime/global_settings.py +1 -1
  76. plain/runtime/user_settings.py +26 -17
  77. plain/runtime/utils.py +1 -1
  78. plain/signals/dispatch/dispatcher.py +39 -17
  79. plain/signing.py +49 -30
  80. plain/templates/jinja/__init__.py +13 -5
  81. plain/templates/jinja/environments.py +4 -3
  82. plain/templates/jinja/extensions.py +9 -3
  83. plain/templates/jinja/filters.py +7 -2
  84. plain/templates/jinja/globals.py +1 -1
  85. plain/test/client.py +246 -174
  86. plain/test/encoding.py +9 -6
  87. plain/test/exceptions.py +10 -2
  88. plain/urls/converters.py +13 -10
  89. plain/urls/patterns.py +32 -20
  90. plain/urls/resolvers.py +32 -22
  91. plain/urls/utils.py +5 -1
  92. plain/utils/cache.py +14 -8
  93. plain/utils/crypto.py +21 -5
  94. plain/utils/datastructures.py +84 -54
  95. plain/utils/dateparse.py +10 -7
  96. plain/utils/deconstruct.py +12 -4
  97. plain/utils/decorators.py +5 -1
  98. plain/utils/duration.py +8 -4
  99. plain/utils/encoding.py +14 -7
  100. plain/utils/functional.py +62 -47
  101. plain/utils/hashable.py +5 -1
  102. plain/utils/html.py +21 -14
  103. plain/utils/http.py +16 -9
  104. plain/utils/inspect.py +14 -6
  105. plain/utils/ipv6.py +7 -3
  106. plain/utils/itercompat.py +6 -1
  107. plain/utils/module_loading.py +7 -3
  108. plain/utils/regex_helper.py +23 -13
  109. plain/utils/safestring.py +14 -6
  110. plain/utils/text.py +34 -18
  111. plain/utils/timezone.py +30 -19
  112. plain/utils/tree.py +31 -18
  113. plain/validators.py +71 -44
  114. plain/views/base.py +16 -6
  115. plain/views/errors.py +11 -4
  116. plain/views/exceptions.py +4 -1
  117. plain/views/objects.py +15 -15
  118. plain/views/redirect.py +14 -10
  119. plain/views/templates.py +1 -1
  120. plain/wsgi.py +3 -1
  121. {plain-0.69.0.dist-info → plain-0.70.0.dist-info}/METADATA +1 -1
  122. plain-0.70.0.dist-info/RECORD +169 -0
  123. plain-0.69.0.dist-info/RECORD +0 -169
  124. {plain-0.69.0.dist-info → plain-0.70.0.dist-info}/WHEEL +0 -0
  125. {plain-0.69.0.dist-info → plain-0.70.0.dist-info}/entry_points.txt +0 -0
  126. {plain-0.69.0.dist-info → plain-0.70.0.dist-info}/licenses/LICENSE +0 -0
@@ -1,3 +1,5 @@
1
+ from __future__ import annotations
2
+
1
3
  import importlib
2
4
  import json
3
5
  import os
@@ -26,13 +28,13 @@ class Settings:
26
28
  Lazy initialization is implemented to defer loading until settings are first accessed.
27
29
  """
28
30
 
29
- def __init__(self, settings_module=None):
31
+ def __init__(self, settings_module: str | None = None):
30
32
  self._settings_module = settings_module
31
33
  self._settings = {}
32
34
  self._errors = [] # Collect configuration errors
33
35
  self.configured = False
34
36
 
35
- def _setup(self):
37
+ def _setup(self) -> None:
36
38
  if self.configured:
37
39
  return
38
40
  else:
@@ -71,7 +73,7 @@ class Settings:
71
73
  # Check for any collected errors
72
74
  self._raise_errors_if_any()
73
75
 
74
- def _load_module_settings(self, module):
76
+ def _load_module_settings(self, module: types.ModuleType) -> None:
75
77
  annotations = getattr(module, "__annotations__", {})
76
78
  settings = dir(module)
77
79
 
@@ -100,7 +102,7 @@ class Settings:
100
102
  required=True,
101
103
  )
102
104
 
103
- def _load_default_settings(self, settings_module):
105
+ def _load_default_settings(self, settings_module: types.ModuleType) -> None:
104
106
  for entry in getattr(settings_module, "INSTALLED_PACKAGES", []):
105
107
  if isinstance(entry, PackageConfig):
106
108
  app_settings = entry.module.default_settings
@@ -111,7 +113,7 @@ class Settings:
111
113
 
112
114
  self._load_module_settings(app_settings)
113
115
 
114
- def _load_env_settings(self):
116
+ def _load_env_settings(self) -> None:
115
117
  env_settings = {
116
118
  k[len(ENV_SETTINGS_PREFIX) :]: v
117
119
  for k, v in os.environ.items()
@@ -128,7 +130,7 @@ class Settings:
128
130
  except ImproperlyConfigured as e:
129
131
  self._errors.append(str(e))
130
132
 
131
- def _load_explicit_settings(self, settings_module):
133
+ def _load_explicit_settings(self, settings_module: types.ModuleType) -> None:
132
134
  for setting in dir(settings_module):
133
135
  if setting.isupper():
134
136
  setting_value = getattr(settings_module, setting)
@@ -172,19 +174,19 @@ class Settings:
172
174
  os.environ["TZ"] = self.TIME_ZONE
173
175
  time.tzset()
174
176
 
175
- def _check_required_settings(self):
177
+ def _check_required_settings(self) -> None:
176
178
  missing = [k for k, v in self._settings.items() if v.required and not v.is_set]
177
179
  if missing:
178
180
  self._errors.append(f"Missing required setting(s): {', '.join(missing)}.")
179
181
 
180
- def _raise_errors_if_any(self):
182
+ def _raise_errors_if_any(self) -> None:
181
183
  if self._errors:
182
184
  errors = ["- " + e for e in self._errors]
183
185
  raise ImproperlyConfigured(
184
186
  "Settings configuration errors:\n" + "\n".join(errors)
185
187
  )
186
188
 
187
- def __getattr__(self, name):
189
+ def __getattr__(self, name: str) -> typing.Any:
188
190
  # Avoid recursion by directly returning internal attributes
189
191
  if not name.isupper():
190
192
  return object.__getattribute__(self, name)
@@ -196,7 +198,7 @@ class Settings:
196
198
  else:
197
199
  raise AttributeError(f"'Settings' object has no attribute '{name}'")
198
200
 
199
- def __setattr__(self, name, value):
201
+ def __setattr__(self, name: str, value: typing.Any) -> None:
200
202
  # Handle internal attributes without recursion
201
203
  if not name.isupper():
202
204
  object.__setattr__(self, name, value)
@@ -207,13 +209,15 @@ class Settings:
207
209
  else:
208
210
  object.__setattr__(self, name, value)
209
211
 
210
- def __repr__(self):
212
+ def __repr__(self) -> str:
211
213
  if not self.configured:
212
214
  return "<Settings [Unevaluated]>"
213
215
  return f'<Settings "{self._settings_module}">'
214
216
 
215
217
 
216
- def _parse_env_value(value, annotation, setting_name):
218
+ def _parse_env_value(
219
+ value: str, annotation: type | None, setting_name: str
220
+ ) -> typing.Any:
217
221
  if not annotation:
218
222
  raise ImproperlyConfigured(
219
223
  f"{setting_name}: Type hint required to set from environment."
@@ -238,7 +242,12 @@ class SettingDefinition:
238
242
  """Store detailed information about settings."""
239
243
 
240
244
  def __init__(
241
- self, name, default_value=None, annotation=None, module=None, required=False
245
+ self,
246
+ name: str,
247
+ default_value: typing.Any = None,
248
+ annotation: type | None = None,
249
+ module: types.ModuleType | None = None,
250
+ required: bool = False,
242
251
  ):
243
252
  self.name = name
244
253
  self.default_value = default_value
@@ -249,13 +258,13 @@ class SettingDefinition:
249
258
  self.source = "default" # 'default', 'env', 'explicit', or 'runtime'
250
259
  self.is_set = False # Indicates if the value was set explicitly
251
260
 
252
- def set_value(self, value, source):
261
+ def set_value(self, value: typing.Any, source: str) -> None:
253
262
  self.check_type(value)
254
263
  self.value = value
255
264
  self.source = source
256
265
  self.is_set = True
257
266
 
258
- def check_type(self, obj):
267
+ def check_type(self, obj: typing.Any) -> None:
259
268
  if not self.annotation:
260
269
  return
261
270
 
@@ -265,7 +274,7 @@ class SettingDefinition:
265
274
  )
266
275
 
267
276
  @staticmethod
268
- def _is_instance_of_type(value, type_hint) -> bool:
277
+ def _is_instance_of_type(value: typing.Any, type_hint: typing.Any) -> bool:
269
278
  # Simple types
270
279
  if isinstance(type_hint, type):
271
280
  return isinstance(value, type_hint)
@@ -300,5 +309,5 @@ class SettingDefinition:
300
309
 
301
310
  raise ValueError(f"Unsupported type hint: {type_hint}")
302
311
 
303
- def __str__(self):
312
+ def __str__(self) -> str:
304
313
  return f"SettingDefinition(name={self.name}, value={self.value}, source={self.source})"
plain/runtime/utils.py CHANGED
@@ -2,7 +2,7 @@ import tomllib
2
2
  from pathlib import Path
3
3
 
4
4
 
5
- def get_app_info_from_pyproject():
5
+ def get_app_info_from_pyproject() -> tuple[str, str]:
6
6
  """Get the project name and version from the nearest pyproject.toml file."""
7
7
  current_path = Path.cwd()
8
8
 
@@ -1,13 +1,20 @@
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
 
11
+ if TYPE_CHECKING:
12
+ from collections.abc import Sequence
13
+
7
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)
@@ -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
 
@@ -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):
206
+ def _log_robust_failure(self, receiver: Callable[..., Any], err: Exception) -> None:
189
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
 
@@ -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
 
@@ -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()
@@ -244,14 +261,16 @@ class Signer:
244
261
 
245
262
 
246
263
  class TimestampSigner(Signer):
247
- def timestamp(self):
264
+ def timestamp(self) -> str:
248
265
  return b62_encode(int(time.time()))
249
266
 
250
- def sign(self, value):
267
+ def sign(self, value: str) -> str:
251
268
  value = f"{value}{self.sep}{self.timestamp()}"
252
269
  return super().sign(value)
253
270
 
254
- def unsign(self, value, max_age=None):
271
+ def unsign(
272
+ self, value: str, max_age: int | float | datetime.timedelta | None = None
273
+ ) -> str:
255
274
  """
256
275
  Retrieve original value and check it wasn't signed more
257
276
  than max_age seconds ago.
@@ -1,3 +1,8 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Callable
4
+ from typing import Any
5
+
1
6
  from plain.packages import packages_registry
2
7
  from plain.runtime import settings
3
8
  from plain.utils.functional import LazyObject
@@ -7,7 +12,7 @@ from .environments import DefaultEnvironment, get_template_dirs
7
12
 
8
13
 
9
14
  class JinjaEnvironment(LazyObject):
10
- def _setup(self):
15
+ def _setup(self) -> None:
11
16
  environment_setting = settings.TEMPLATES_JINJA_ENVIRONMENT
12
17
 
13
18
  if isinstance(environment_setting, str):
@@ -25,12 +30,12 @@ class JinjaEnvironment(LazyObject):
25
30
  environment = JinjaEnvironment()
26
31
 
27
32
 
28
- def register_template_extension(extension_class):
33
+ def register_template_extension(extension_class: type) -> type:
29
34
  environment.add_extension(extension_class)
30
35
  return extension_class
31
36
 
32
37
 
33
- def register_template_global(value, name=None):
38
+ def register_template_global(value: Any, name: str | None = None) -> Any:
34
39
  """
35
40
  Adds a global to the Jinja environment.
36
41
 
@@ -54,9 +59,12 @@ def register_template_global(value, name=None):
54
59
  return value
55
60
 
56
61
 
57
- def register_template_filter(func, name=None):
62
+ def register_template_filter(
63
+ func: Callable[..., Any], name: str | None = None
64
+ ) -> Callable[..., Any]:
58
65
  """Adds a filter to the Jinja environment."""
59
- environment.filters[name or func.__name__] = func
66
+ filter_name = name if name is not None else func.__name__ # type: ignore[attr-defined]
67
+ environment.filters[filter_name] = func
60
68
  return func
61
69
 
62
70
 
@@ -1,5 +1,6 @@
1
1
  import functools
2
2
  from pathlib import Path
3
+ from typing import Any
3
4
 
4
5
  from jinja2 import Environment, StrictUndefined
5
6
  from jinja2.loaders import FileSystemLoader
@@ -11,7 +12,7 @@ from .filters import default_filters
11
12
  from .globals import default_globals
12
13
 
13
14
 
14
- def finalize_callable_error(obj):
15
+ def finalize_callable_error(obj: Any) -> Any:
15
16
  """Prevent direct rendering of a callable (likely just forgotten ()) by raising a TypeError"""
16
17
  if callable(obj):
17
18
  raise TypeError(f"{obj} is callable, did you forget parentheses?")
@@ -23,14 +24,14 @@ def finalize_callable_error(obj):
23
24
  return obj
24
25
 
25
26
 
26
- def get_template_dirs():
27
+ def get_template_dirs() -> tuple[Path, ...]:
27
28
  jinja_templates = Path(__file__).parent / "templates"
28
29
  app_templates = settings.path.parent / "templates"
29
30
  return (jinja_templates, app_templates) + _get_app_template_dirs()
30
31
 
31
32
 
32
33
  @functools.lru_cache
33
- def _get_app_template_dirs():
34
+ def _get_app_template_dirs() -> tuple[Path, ...]:
34
35
  """
35
36
  Return an iterable of paths of directories to load app templates from.
36
37
 
@@ -1,3 +1,7 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
1
5
  from jinja2 import nodes
2
6
  from jinja2.ext import Extension
3
7
 
@@ -9,7 +13,7 @@ class InclusionTagExtension(Extension):
9
13
  tags: set[str]
10
14
  template_name: str
11
15
 
12
- def parse(self, parser):
16
+ def parse(self, parser: Any) -> nodes.Node:
13
17
  lineno = next(parser.stream).lineno
14
18
  args = [
15
19
  nodes.DerivedContextReference(),
@@ -28,12 +32,14 @@ class InclusionTagExtension(Extension):
28
32
  call = self.call_method("_render", args=args, kwargs=kwargs, lineno=lineno)
29
33
  return nodes.CallBlock(call, [], [], []).set_lineno(lineno)
30
34
 
31
- def _render(self, context, *args, **kwargs):
35
+ def _render(self, context: dict[str, Any], *args: Any, **kwargs: Any) -> str:
32
36
  context = self.get_context(context, *args, **kwargs)
33
37
  template = self.environment.get_template(self.template_name)
34
38
  return template.render(context)
35
39
 
36
- def get_context(self, context, *args, **kwargs):
40
+ def get_context(
41
+ self, context: dict[str, Any], *args: Any, **kwargs: Any
42
+ ) -> dict[str, Any]:
37
43
  raise NotImplementedError(
38
44
  "You need to implement the `get_context` method in your subclass."
39
45
  )
@@ -1,12 +1,17 @@
1
+ from __future__ import annotations
2
+
1
3
  import datetime
2
4
  from itertools import islice
5
+ from typing import Any
3
6
 
4
7
  from plain.utils.html import json_script
5
8
  from plain.utils.timesince import timesince, timeuntil
6
9
  from plain.utils.timezone import localtime
7
10
 
8
11
 
9
- def localtime_filter(value, timezone=None):
12
+ def localtime_filter(
13
+ value: datetime.datetime | None, timezone: Any = None
14
+ ) -> datetime.datetime:
10
15
  """Converts a datetime to local time in a template."""
11
16
  if not value:
12
17
  # Without this, we get the current localtime
@@ -15,7 +20,7 @@ def localtime_filter(value, timezone=None):
15
20
  return localtime(value, timezone)
16
21
 
17
22
 
18
- def pluralize_filter(value, singular="", plural="s"):
23
+ def pluralize_filter(value: Any, singular: str = "", plural: str = "s") -> str:
19
24
  """Returns plural suffix based on the value count.
20
25
 
21
26
  Usage:
@@ -5,7 +5,7 @@ from plain.urls import reverse
5
5
  from plain.utils import timezone
6
6
 
7
7
 
8
- def asset(url_path):
8
+ def asset(url_path: str) -> str:
9
9
  # An explicit callable we can control, but also delay the import of asset.urls->views->templates
10
10
  # for circular import reasons
11
11
  from plain.assets.urls import get_asset_url