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,9 +1,13 @@
1
+ from __future__ import annotations
2
+
1
3
  import os
2
4
  import sys
3
5
  from importlib import import_module
6
+ from types import ModuleType
7
+ from typing import Any
4
8
 
5
9
 
6
- def cached_import(module_path, class_name):
10
+ def cached_import(module_path: str, class_name: str) -> Any:
7
11
  # Check whether module is loaded and fully initialized.
8
12
  if not (
9
13
  (module := sys.modules.get(module_path))
@@ -14,7 +18,7 @@ def cached_import(module_path, class_name):
14
18
  return getattr(module, class_name)
15
19
 
16
20
 
17
- def import_string(dotted_path):
21
+ def import_string(dotted_path: str) -> Any:
18
22
  """
19
23
  Import a dotted module path and return the attribute/class designated by the
20
24
  last name in the path. Raise ImportError if the import failed.
@@ -32,7 +36,7 @@ def import_string(dotted_path):
32
36
  ) from err
33
37
 
34
38
 
35
- def module_dir(module):
39
+ def module_dir(module: ModuleType) -> str:
36
40
  """
37
41
  Find the name of the directory that contains a module, if possible.
38
42
 
@@ -6,14 +6,19 @@ This is not, and is not intended to be, a complete reg-exp decompiler. It
6
6
  should be good enough for a large class of URLS, however.
7
7
  """
8
8
 
9
+ from __future__ import annotations
10
+
9
11
  import re
12
+ from collections.abc import Iterator
13
+ from typing import Any
10
14
 
15
+ from plain.internal import internalcode
11
16
  from plain.utils.functional import SimpleLazyObject
12
17
 
13
18
  # Mapping of an escape character to a representative of that class. So, e.g.,
14
19
  # "\w" is replaced by "x" in a reverse URL. A value of None means to ignore
15
20
  # this sequence. Any missing key is mapped to itself.
16
- ESCAPE_MAPPINGS = {
21
+ _ESCAPE_MAPPINGS = {
17
22
  "A": None,
18
23
  "b": None,
19
24
  "B": None,
@@ -27,19 +32,22 @@ ESCAPE_MAPPINGS = {
27
32
  }
28
33
 
29
34
 
35
+ @internalcode
30
36
  class Choice(list):
31
37
  """Represent multiple possibilities at this point in a pattern string."""
32
38
 
33
39
 
40
+ @internalcode
34
41
  class Group(list):
35
42
  """Represent a capturing group in the pattern string."""
36
43
 
37
44
 
45
+ @internalcode
38
46
  class NonCapture(list):
39
47
  """Represent a non-capturing group in the pattern string."""
40
48
 
41
49
 
42
- def normalize(pattern):
50
+ def _normalize(pattern: str) -> list[tuple[str, list[str | None]]]:
43
51
  r"""
44
52
  Given a reg-exp pattern, normalize it to an iterable of forms that
45
53
  suffice for reverse matching. This does the following:
@@ -65,7 +73,7 @@ def normalize(pattern):
65
73
  result = []
66
74
  non_capturing_groups = []
67
75
  consume_next = True
68
- pattern_iter = next_char(iter(pattern))
76
+ pattern_iter = _next_char(iter(pattern))
69
77
  num_args = 0
70
78
 
71
79
  # A "while" loop is used here because later on we need to be able to peek
@@ -115,13 +123,13 @@ def normalize(pattern):
115
123
  name = "_%d" % num_args # noqa: UP031
116
124
  num_args += 1
117
125
  result.append(Group(((f"%({name})s"), name)))
118
- walk_to_end(ch, pattern_iter)
126
+ _walk_to_end(ch, pattern_iter)
119
127
  else:
120
128
  ch, escaped = next(pattern_iter)
121
129
  if ch in "!=<":
122
130
  # All of these are ignorable. Walk to the end of the
123
131
  # group.
124
- walk_to_end(ch, pattern_iter)
132
+ _walk_to_end(ch, pattern_iter)
125
133
  elif ch == ":":
126
134
  # Non-capturing group
127
135
  non_capturing_groups.append(len(result))
@@ -152,12 +160,12 @@ def normalize(pattern):
152
160
  # parenthesis.
153
161
  if terminal_char != ")":
154
162
  result.append(Group(((f"%({param})s"), param)))
155
- walk_to_end(ch, pattern_iter)
163
+ _walk_to_end(ch, pattern_iter)
156
164
  else:
157
165
  result.append(Group(((f"%({param})s"), None)))
158
166
  elif ch in "*?+{":
159
167
  # Quantifiers affect the previous item in the result list.
160
- count, ch = get_quantifier(ch, pattern_iter)
168
+ count, ch = _get_quantifier(ch, pattern_iter)
161
169
  if ch:
162
170
  # We had to look ahead, but it wasn't need to compute the
163
171
  # quantifier, so use this character next time around the
@@ -165,7 +173,7 @@ def normalize(pattern):
165
173
  consume_next = False
166
174
 
167
175
  if count == 0:
168
- if contains(result[-1], Group):
176
+ if _contains(result[-1], Group):
169
177
  # If we are quantifying a capturing group (or
170
178
  # something containing such a group) and the minimum is
171
179
  # zero, we must also handle the case of one occurrence
@@ -190,10 +198,10 @@ def normalize(pattern):
190
198
  # A case of using the disjunctive form. No results for you!
191
199
  return [("", [])]
192
200
 
193
- return list(zip(*flatten_result(result)))
201
+ return list(zip(*_flatten_result(result)))
194
202
 
195
203
 
196
- def next_char(input_iter):
204
+ def _next_char(input_iter: Iterator[str]) -> Iterator[tuple[str, bool]]:
197
205
  r"""
198
206
  An iterator that yields the next character from "pattern_iter", respecting
199
207
  escape sequences. An escaped character is replaced by a representative of
@@ -208,13 +216,13 @@ def next_char(input_iter):
208
216
  yield ch, False
209
217
  continue
210
218
  ch = next(input_iter)
211
- representative = ESCAPE_MAPPINGS.get(ch, ch)
219
+ representative = _ESCAPE_MAPPINGS.get(ch, ch)
212
220
  if representative is None:
213
221
  continue
214
222
  yield representative, True
215
223
 
216
224
 
217
- def walk_to_end(ch, input_iter):
225
+ def _walk_to_end(ch: str, input_iter: Iterator[tuple[str, bool]]) -> None:
218
226
  """
219
227
  The iterator is currently inside a capturing group. Walk to the close of
220
228
  this group, skipping over any nested groups and handling escaped
@@ -235,7 +243,9 @@ def walk_to_end(ch, input_iter):
235
243
  nesting -= 1
236
244
 
237
245
 
238
- def get_quantifier(ch, input_iter):
246
+ def _get_quantifier(
247
+ ch: str, input_iter: Iterator[tuple[str, bool]]
248
+ ) -> tuple[int, str | None]:
239
249
  """
240
250
  Parse a quantifier from the input, where "ch" is the first character in the
241
251
  quantifier.
@@ -263,16 +273,18 @@ def get_quantifier(ch, input_iter):
263
273
  values = "".join(quant).split(",")
264
274
 
265
275
  # Consume the trailing '?', if necessary.
276
+ ch2: str | None
266
277
  try:
267
278
  ch, escaped = next(input_iter)
279
+ ch2 = ch
268
280
  except StopIteration:
269
- ch = None
270
- if ch == "?":
271
- ch = None
272
- return int(values[0]), ch
281
+ ch2 = None
282
+ if ch2 == "?":
283
+ ch2 = None
284
+ return int(values[0]), ch2
273
285
 
274
286
 
275
- def contains(source, inst):
287
+ def _contains(source: Any, inst: type) -> bool:
276
288
  """
277
289
  Return True if the "source" contains an instance of "inst". False,
278
290
  otherwise.
@@ -281,12 +293,12 @@ def contains(source, inst):
281
293
  return True
282
294
  if isinstance(source, NonCapture):
283
295
  for elt in source:
284
- if contains(elt, inst):
296
+ if _contains(elt, inst):
285
297
  return True
286
298
  return False
287
299
 
288
300
 
289
- def flatten_result(source):
301
+ def _flatten_result(source: Any) -> tuple[list[str], list[list[str | None]]]:
290
302
  """
291
303
  Turn the given source sequence into a list of reg-exp possibilities and
292
304
  their arguments. Return a list of strings and a list of argument lists.
@@ -322,7 +334,7 @@ def flatten_result(source):
322
334
  elt = [elt]
323
335
  inner_result, inner_args = [], []
324
336
  for item in elt:
325
- res, args = flatten_result(item)
337
+ res, args = _flatten_result(item)
326
338
  inner_result.extend(res)
327
339
  inner_args.extend(args)
328
340
  new_result = []
@@ -340,10 +352,12 @@ def flatten_result(source):
340
352
  return result, result_args
341
353
 
342
354
 
343
- def _lazy_re_compile(regex, flags=0):
355
+ def _lazy_re_compile(
356
+ regex: str | bytes | re.Pattern[str] | re.Pattern[bytes], flags: int = 0
357
+ ) -> SimpleLazyObject:
344
358
  """Lazily compile a regex with flags."""
345
359
 
346
- def _compile():
360
+ def _compile() -> re.Pattern[str] | re.Pattern[bytes]:
347
361
  # Compile the regex if it was not passed pre-compiled.
348
362
  if isinstance(regex, str | bytes):
349
363
  return re.compile(regex, flags)
plain/utils/safestring.py CHANGED
@@ -5,15 +5,21 @@ that the producer of the string has already turned characters that should not
5
5
  be interpreted by the HTML engine (e.g. '<') into the appropriate entities.
6
6
  """
7
7
 
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import Callable
8
11
  from functools import wraps
12
+ from typing import Any, TypeVar
9
13
 
10
14
  from plain.utils.functional import keep_lazy
11
15
 
16
+ _T = TypeVar("_T")
17
+
12
18
 
13
19
  class SafeData:
14
20
  __slots__ = ()
15
21
 
16
- def __html__(self):
22
+ def __html__(self) -> SafeData:
17
23
  """
18
24
  Return the html representation of a string for interoperability.
19
25
 
@@ -30,7 +36,7 @@ class SafeString(str, SafeData):
30
36
 
31
37
  __slots__ = ()
32
38
 
33
- def __add__(self, rhs):
39
+ def __add__(self, rhs: str) -> SafeString | str: # type: ignore[override]
34
40
  """
35
41
  Concatenating a safe string with another safe bytestring or
36
42
  safe string is safe. Otherwise, the result is no longer safe.
@@ -40,20 +46,22 @@ class SafeString(str, SafeData):
40
46
  return SafeString(t)
41
47
  return t
42
48
 
43
- def __str__(self):
49
+ def __str__(self) -> str:
44
50
  return self
45
51
 
46
52
 
47
- def _safety_decorator(safety_marker, func):
53
+ def _safety_decorator(
54
+ safety_marker: Callable[[Any], _T], func: Callable[..., Any]
55
+ ) -> Callable[..., _T]:
48
56
  @wraps(func)
49
- def wrapper(*args, **kwargs):
57
+ def wrapper(*args: Any, **kwargs: Any) -> _T:
50
58
  return safety_marker(func(*args, **kwargs))
51
59
 
52
60
  return wrapper
53
61
 
54
62
 
55
63
  @keep_lazy(SafeString)
56
- def mark_safe(s):
64
+ def mark_safe(s: Any) -> SafeString | SafeData | Callable[..., Any]:
57
65
  """
58
66
  Explicitly mark a string as safe for (HTML) output purposes. The returned
59
67
  object can be used everywhere a string is appropriate.
plain/utils/text.py CHANGED
@@ -1,13 +1,16 @@
1
+ from __future__ import annotations
2
+
1
3
  import re
2
4
  import unicodedata
5
+ from typing import Any
3
6
 
4
7
  from plain.utils.functional import SimpleLazyObject, keep_lazy_text, lazy
5
8
  from plain.utils.regex_helper import _lazy_re_compile
6
9
 
7
10
  # Set up regular expressions
8
- re_words = _lazy_re_compile(r"<[^>]+?>|([^<>\s]+)", re.S)
9
- re_chars = _lazy_re_compile(r"<[^>]+?>|(.)", re.S)
10
- re_tag = _lazy_re_compile(r"<(/)?(\S+?)(?:(\s*/)|\s.*?)?>", re.S)
11
+ _re_words = _lazy_re_compile(r"<[^>]+?>|([^<>\s]+)", re.S)
12
+ _re_chars = _lazy_re_compile(r"<[^>]+?>|(.)", re.S)
13
+ _re_tag = _lazy_re_compile(r"<(/)?(\S+?)(?:(\s*/)|\s.*?)?>", re.S)
11
14
 
12
15
 
13
16
  class Truncator(SimpleLazyObject):
@@ -15,10 +18,12 @@ class Truncator(SimpleLazyObject):
15
18
  An object used to truncate text, either by characters or words.
16
19
  """
17
20
 
18
- def __init__(self, text):
21
+ _wrapped: str # Override parent type since we always store str
22
+
23
+ def __init__(self, text: Any):
19
24
  super().__init__(lambda: str(text))
20
25
 
21
- def add_truncation_text(self, text, truncate=None):
26
+ def add_truncation_text(self, text: str, truncate: str | None = None) -> str:
22
27
  if truncate is None:
23
28
  truncate = "%(truncated_text)s…"
24
29
  if "%(truncated_text)s" in truncate:
@@ -31,7 +36,7 @@ class Truncator(SimpleLazyObject):
31
36
  return text
32
37
  return f"{text}{truncate}"
33
38
 
34
- def chars(self, num, truncate=None, html=False):
39
+ def chars(self, num: int, truncate: str | None = None, html: bool = False) -> str:
35
40
  """
36
41
  Return the text truncated to be no longer than the specified number
37
42
  of characters.
@@ -54,7 +59,9 @@ class Truncator(SimpleLazyObject):
54
59
  return self._truncate_html(length, truncate, text, truncate_len, False)
55
60
  return self._text_chars(length, truncate, text, truncate_len)
56
61
 
57
- def _text_chars(self, length, truncate, text, truncate_len):
62
+ def _text_chars(
63
+ self, length: int, truncate: str | None, text: str, truncate_len: int
64
+ ) -> str:
58
65
  """Truncate a string after a certain number of chars."""
59
66
  s_len = 0
60
67
  end_index = None
@@ -73,7 +80,7 @@ class Truncator(SimpleLazyObject):
73
80
  # Return the original string since no truncation was necessary
74
81
  return text
75
82
 
76
- def words(self, num, truncate=None, html=False):
83
+ def words(self, num: int, truncate: str | None = None, html: bool = False) -> str:
77
84
  """
78
85
  Truncate a string after a certain number of words. `truncate` specifies
79
86
  what should be used to notify that the string has been truncated,
@@ -85,7 +92,7 @@ class Truncator(SimpleLazyObject):
85
92
  return self._truncate_html(length, truncate, self._wrapped, length, True)
86
93
  return self._text_words(length, truncate)
87
94
 
88
- def _text_words(self, length, truncate):
95
+ def _text_words(self, length: int, truncate: str | None) -> str:
89
96
  """
90
97
  Truncate a string after a certain number of words.
91
98
 
@@ -97,7 +104,14 @@ class Truncator(SimpleLazyObject):
97
104
  return self.add_truncation_text(" ".join(words), truncate)
98
105
  return " ".join(words)
99
106
 
100
- def _truncate_html(self, length, truncate, text, truncate_len, words):
107
+ def _truncate_html(
108
+ self,
109
+ length: int,
110
+ truncate: str | None,
111
+ text: str,
112
+ truncate_len: int,
113
+ words: bool,
114
+ ) -> str:
101
115
  """
102
116
  Truncate HTML to a certain number of chars (not counting tags and
103
117
  comments), or, if words is True, then to a certain number of words.
@@ -126,7 +140,7 @@ class Truncator(SimpleLazyObject):
126
140
  current_len = 0
127
141
  open_tags = []
128
142
 
129
- regex = re_words if words else re_chars
143
+ regex = _re_words if words else _re_chars
130
144
 
131
145
  while current_len <= length:
132
146
  m = regex.search(text, pos)
@@ -141,7 +155,7 @@ class Truncator(SimpleLazyObject):
141
155
  end_text_pos = pos
142
156
  continue
143
157
  # Check for tag
144
- tag = re_tag.match(m[0])
158
+ tag = _re_tag.match(m[0])
145
159
  if not tag or current_len >= truncate_len:
146
160
  # Don't worry about non tags or tags after our truncate point
147
161
  continue
@@ -178,7 +192,7 @@ class Truncator(SimpleLazyObject):
178
192
 
179
193
 
180
194
  @keep_lazy_text
181
- def slugify(value, allow_unicode=False):
195
+ def slugify(value: Any, allow_unicode: bool = False) -> str:
182
196
  """
183
197
  Convert to ASCII if 'allow_unicode' is False. Convert spaces or repeated
184
198
  dashes to single dashes. Remove characters that aren't alphanumerics,
@@ -198,18 +212,22 @@ def slugify(value, allow_unicode=False):
198
212
  return re.sub(r"[-\s]+", "-", value).strip("-_")
199
213
 
200
214
 
201
- def pluralize(singular, plural, number):
215
+ def pluralize(singular: str, plural: str, number: int) -> str:
202
216
  if number == 1:
203
217
  return singular
204
218
  else:
205
219
  return plural
206
220
 
207
221
 
208
- def pluralize_lazy(singular, plural, number):
209
- def _lazy_number_unpickle(func, resultclass, number, kwargs):
222
+ def pluralize_lazy(singular: str, plural: str, number: int | str) -> Any:
223
+ def _lazy_number_unpickle(
224
+ func: Any, resultclass: Any, number: Any, kwargs: dict[str, Any]
225
+ ) -> Any:
210
226
  return lazy_number(func, resultclass, number=number, **kwargs)
211
227
 
212
- def lazy_number(func, resultclass, number=None, **kwargs):
228
+ def lazy_number(
229
+ func: Any, resultclass: Any, number: int | str | None = None, **kwargs: Any
230
+ ) -> Any:
213
231
  if isinstance(number, int):
214
232
  kwargs["number"] = number
215
233
  proxy = lazy(func, resultclass)(**kwargs)
@@ -217,12 +235,12 @@ def pluralize_lazy(singular, plural, number):
217
235
  original_kwargs = kwargs.copy()
218
236
 
219
237
  class NumberAwareString(resultclass):
220
- def __bool__(self):
238
+ def __bool__(self) -> bool:
221
239
  return bool(kwargs["singular"])
222
240
 
223
- def _get_number_value(self, values):
241
+ def _get_number_value(self, values: dict[str, Any]) -> Any:
224
242
  try:
225
- return values[number]
243
+ return values[number] # type: ignore[index]
226
244
  except KeyError:
227
245
  raise KeyError(
228
246
  f"Your dictionary lacks key '{number}'. Please provide "
@@ -230,17 +248,17 @@ def pluralize_lazy(singular, plural, number):
230
248
  "string is singular or plural."
231
249
  )
232
250
 
233
- def _translate(self, number_value):
251
+ def _translate(self, number_value: int) -> str:
234
252
  kwargs["number"] = number_value
235
253
  return func(**kwargs)
236
254
 
237
- def format(self, *args, **kwargs):
255
+ def format(self, *args: Any, **kwargs: Any) -> str:
238
256
  number_value = (
239
257
  self._get_number_value(kwargs) if kwargs and number else args[0]
240
258
  )
241
259
  return self._translate(number_value).format(*args, **kwargs)
242
260
 
243
- def __mod__(self, rhs):
261
+ def __mod__(self, rhs: Any) -> str:
244
262
  if isinstance(rhs, dict) and number:
245
263
  number_value = self._get_number_value(rhs)
246
264
  else:
plain/utils/timezone.py CHANGED
@@ -2,11 +2,14 @@
2
2
  Timezone-related classes and functions.
3
3
  """
4
4
 
5
+ from __future__ import annotations
6
+
5
7
  import functools
6
8
  import zoneinfo
7
9
  from contextlib import ContextDecorator
8
- from datetime import UTC, datetime, timedelta, timezone, tzinfo
10
+ from datetime import UTC, datetime, time, timedelta, timezone, tzinfo
9
11
  from threading import local
12
+ from types import TracebackType
10
13
 
11
14
  from plain.runtime import settings
12
15
 
@@ -28,10 +31,10 @@ __all__ = [
28
31
  ]
29
32
 
30
33
 
31
- def get_fixed_timezone(offset):
34
+ def get_fixed_timezone(offset: int | timedelta) -> timezone:
32
35
  """Return a tzinfo instance with a fixed offset from UTC."""
33
36
  if isinstance(offset, timedelta):
34
- offset = offset.total_seconds() // 60
37
+ offset = int(offset.total_seconds() // 60)
35
38
  sign = "-" if offset < 0 else "+"
36
39
  hhmm = "%02d%02d" % divmod(abs(offset), 60) # noqa: UP031
37
40
  name = sign + hhmm
@@ -41,7 +44,7 @@ def get_fixed_timezone(offset):
41
44
  # In order to avoid accessing settings at compile time,
42
45
  # wrap the logic in a function and cache the result.
43
46
  @functools.lru_cache
44
- def get_default_timezone():
47
+ def get_default_timezone() -> zoneinfo.ZoneInfo:
45
48
  """
46
49
  Return the default time zone as a tzinfo instance.
47
50
 
@@ -51,7 +54,7 @@ def get_default_timezone():
51
54
 
52
55
 
53
56
  # This function exists for consistency with get_current_timezone_name
54
- def get_default_timezone_name():
57
+ def get_default_timezone_name() -> str:
55
58
  """Return the name of the default time zone."""
56
59
  return _get_timezone_name(get_default_timezone())
57
60
 
@@ -59,17 +62,17 @@ def get_default_timezone_name():
59
62
  _active = local()
60
63
 
61
64
 
62
- def get_current_timezone():
65
+ def get_current_timezone() -> tzinfo:
63
66
  """Return the currently active time zone as a tzinfo instance."""
64
67
  return getattr(_active, "value", get_default_timezone())
65
68
 
66
69
 
67
- def get_current_timezone_name():
70
+ def get_current_timezone_name() -> str:
68
71
  """Return the name of the currently active time zone."""
69
72
  return _get_timezone_name(get_current_timezone())
70
73
 
71
74
 
72
- def _get_timezone_name(timezone):
75
+ def _get_timezone_name(timezone: tzinfo) -> str:
73
76
  """
74
77
  Return the offset for fixed offset timezones, or the name of timezone if
75
78
  not set.
@@ -83,7 +86,7 @@ def _get_timezone_name(timezone):
83
86
  # because it isn't thread safe.
84
87
 
85
88
 
86
- def activate(timezone):
89
+ def activate(timezone: tzinfo | str) -> None:
87
90
  """
88
91
  Set the time zone for the current thread.
89
92
 
@@ -98,7 +101,7 @@ def activate(timezone):
98
101
  raise ValueError(f"Invalid timezone: {timezone!r}")
99
102
 
100
103
 
101
- def deactivate():
104
+ def deactivate() -> None:
102
105
  """
103
106
  Unset the time zone for the current thread.
104
107
 
@@ -121,17 +124,23 @@ class override(ContextDecorator):
121
124
  time zone.
122
125
  """
123
126
 
124
- def __init__(self, timezone):
127
+ def __init__(self, timezone: tzinfo | str | None) -> None:
125
128
  self.timezone = timezone
129
+ self.old_timezone: tzinfo | None = None
126
130
 
127
- def __enter__(self):
131
+ def __enter__(self) -> None:
128
132
  self.old_timezone = getattr(_active, "value", None)
129
133
  if self.timezone is None:
130
134
  deactivate()
131
135
  else:
132
136
  activate(self.timezone)
133
137
 
134
- def __exit__(self, exc_type, exc_value, traceback):
138
+ def __exit__(
139
+ self,
140
+ exc_type: type[BaseException] | None,
141
+ exc_value: BaseException | None,
142
+ traceback: TracebackType | None,
143
+ ) -> None:
135
144
  if self.old_timezone is None:
136
145
  deactivate()
137
146
  else:
@@ -141,7 +150,9 @@ class override(ContextDecorator):
141
150
  # Utilities
142
151
 
143
152
 
144
- def localtime(value=None, timezone=None):
153
+ def localtime(
154
+ value: datetime | None = None, timezone: tzinfo | None = None
155
+ ) -> datetime:
145
156
  """
146
157
  Convert an aware datetime.datetime to local time.
147
158
 
@@ -161,7 +172,7 @@ def localtime(value=None, timezone=None):
161
172
  return value.astimezone(timezone)
162
173
 
163
174
 
164
- def now():
175
+ def now() -> datetime:
165
176
  """
166
177
  Return a timezone aware datetime.
167
178
  """
@@ -172,9 +183,9 @@ def now():
172
183
  # The caller should ensure that they don't receive an invalid value like None.
173
184
 
174
185
 
175
- def is_aware(value):
186
+ def is_aware(value: datetime | time) -> bool:
176
187
  """
177
- Determine if a given datetime.datetime is aware.
188
+ Determine if a given datetime.datetime or datetime.time is aware.
178
189
 
179
190
  The concept is defined in Python's docs:
180
191
  https://docs.python.org/library/datetime.html#datetime.tzinfo
@@ -185,9 +196,9 @@ def is_aware(value):
185
196
  return value.utcoffset() is not None
186
197
 
187
198
 
188
- def is_naive(value):
199
+ def is_naive(value: datetime | time) -> bool:
189
200
  """
190
- Determine if a given datetime.datetime is naive.
201
+ Determine if a given datetime.datetime or datetime.time is naive.
191
202
 
192
203
  The concept is defined in Python's docs:
193
204
  https://docs.python.org/library/datetime.html#datetime.tzinfo
@@ -198,7 +209,7 @@ def is_naive(value):
198
209
  return value.utcoffset() is None
199
210
 
200
211
 
201
- def make_aware(value, timezone=None):
212
+ def make_aware(value: datetime, timezone: tzinfo | None = None) -> datetime:
202
213
  """Make a naive datetime.datetime in a given time zone aware."""
203
214
  if timezone is None:
204
215
  timezone = get_current_timezone()
@@ -209,7 +220,7 @@ def make_aware(value, timezone=None):
209
220
  return value.replace(tzinfo=timezone)
210
221
 
211
222
 
212
- def make_naive(value, timezone=None):
223
+ def make_naive(value: datetime, timezone: tzinfo | None = None) -> datetime:
213
224
  """Make an aware datetime.datetime naive in a given time zone."""
214
225
  if timezone is None:
215
226
  timezone = get_current_timezone()
@@ -219,5 +230,5 @@ def make_naive(value, timezone=None):
219
230
  return value.astimezone(timezone).replace(tzinfo=None)
220
231
 
221
232
 
222
- def _datetime_ambiguous_or_imaginary(dt, tz):
233
+ def _datetime_ambiguous_or_imaginary(dt: datetime, tz: tzinfo) -> bool:
223
234
  return tz.utcoffset(dt.replace(fold=not dt.fold)) != tz.utcoffset(dt)