plain 0.68.0__py3-none-any.whl → 0.103.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- plain/CHANGELOG.md +684 -1
- plain/README.md +1 -1
- plain/agents/.claude/rules/plain.md +88 -0
- plain/agents/.claude/skills/plain-install/SKILL.md +26 -0
- plain/agents/.claude/skills/plain-upgrade/SKILL.md +35 -0
- plain/assets/compile.py +25 -12
- plain/assets/finders.py +24 -17
- plain/assets/fingerprints.py +10 -7
- plain/assets/urls.py +1 -1
- plain/assets/views.py +47 -33
- plain/chores/README.md +25 -23
- plain/chores/__init__.py +2 -1
- plain/chores/core.py +27 -0
- plain/chores/registry.py +23 -36
- plain/cli/README.md +185 -16
- plain/cli/__init__.py +2 -1
- plain/cli/agent.py +234 -0
- plain/cli/build.py +7 -8
- plain/cli/changelog.py +11 -5
- plain/cli/chores.py +32 -34
- plain/cli/core.py +110 -26
- plain/cli/docs.py +98 -21
- plain/cli/formatting.py +40 -17
- plain/cli/install.py +10 -54
- plain/cli/{agent/llmdocs.py → llmdocs.py} +45 -26
- plain/cli/output.py +6 -2
- plain/cli/preflight.py +27 -75
- plain/cli/print.py +4 -4
- plain/cli/registry.py +96 -10
- plain/cli/{agent/request.py → request.py} +67 -33
- plain/cli/runtime.py +45 -0
- plain/cli/scaffold.py +2 -7
- plain/cli/server.py +153 -0
- plain/cli/settings.py +53 -49
- plain/cli/shell.py +15 -12
- plain/cli/startup.py +9 -8
- plain/cli/upgrade.py +17 -104
- plain/cli/urls.py +12 -7
- plain/cli/utils.py +3 -3
- plain/csrf/README.md +65 -40
- plain/csrf/middleware.py +53 -43
- plain/debug.py +5 -2
- plain/exceptions.py +22 -114
- plain/forms/README.md +453 -24
- plain/forms/__init__.py +55 -4
- plain/forms/boundfield.py +15 -8
- plain/forms/exceptions.py +1 -1
- plain/forms/fields.py +346 -143
- plain/forms/forms.py +75 -45
- plain/http/README.md +356 -9
- plain/http/__init__.py +41 -26
- plain/http/cookie.py +15 -7
- plain/http/exceptions.py +65 -0
- plain/http/middleware.py +32 -0
- plain/http/multipartparser.py +99 -88
- plain/http/request.py +362 -250
- plain/http/response.py +99 -197
- plain/internal/__init__.py +8 -1
- plain/internal/files/base.py +35 -19
- plain/internal/files/locks.py +19 -11
- plain/internal/files/move.py +8 -3
- plain/internal/files/temp.py +25 -6
- plain/internal/files/uploadedfile.py +47 -28
- plain/internal/files/uploadhandler.py +64 -58
- plain/internal/files/utils.py +24 -10
- plain/internal/handlers/base.py +34 -23
- plain/internal/handlers/exception.py +68 -65
- plain/internal/handlers/wsgi.py +65 -54
- plain/internal/middleware/headers.py +37 -11
- plain/internal/middleware/hosts.py +11 -8
- plain/internal/middleware/https.py +17 -7
- plain/internal/middleware/slash.py +14 -9
- plain/internal/reloader.py +77 -0
- plain/json.py +2 -1
- plain/logs/README.md +161 -62
- plain/logs/__init__.py +1 -1
- plain/logs/{loggers.py → app.py} +71 -67
- plain/logs/configure.py +63 -14
- plain/logs/debug.py +17 -6
- plain/logs/filters.py +15 -0
- plain/logs/formatters.py +7 -4
- plain/packages/README.md +105 -23
- plain/packages/config.py +15 -7
- plain/packages/registry.py +27 -16
- plain/paginator.py +31 -21
- plain/preflight/README.md +209 -24
- plain/preflight/__init__.py +1 -0
- plain/preflight/checks.py +3 -1
- plain/preflight/files.py +3 -1
- plain/preflight/registry.py +26 -11
- plain/preflight/results.py +15 -7
- plain/preflight/security.py +15 -13
- plain/preflight/settings.py +54 -0
- plain/preflight/urls.py +4 -1
- plain/runtime/README.md +115 -47
- plain/runtime/__init__.py +10 -6
- plain/runtime/global_settings.py +34 -25
- plain/runtime/secret.py +20 -0
- plain/runtime/user_settings.py +110 -38
- plain/runtime/utils.py +1 -1
- plain/server/LICENSE +35 -0
- plain/server/README.md +155 -0
- plain/server/__init__.py +9 -0
- plain/server/app.py +52 -0
- plain/server/arbiter.py +555 -0
- plain/server/config.py +118 -0
- plain/server/errors.py +31 -0
- plain/server/glogging.py +292 -0
- plain/server/http/__init__.py +12 -0
- plain/server/http/body.py +283 -0
- plain/server/http/errors.py +155 -0
- plain/server/http/message.py +400 -0
- plain/server/http/parser.py +70 -0
- plain/server/http/unreader.py +88 -0
- plain/server/http/wsgi.py +421 -0
- plain/server/pidfile.py +92 -0
- plain/server/sock.py +240 -0
- plain/server/util.py +317 -0
- plain/server/workers/__init__.py +6 -0
- plain/server/workers/base.py +304 -0
- plain/server/workers/sync.py +212 -0
- plain/server/workers/thread.py +399 -0
- plain/server/workers/workertmp.py +50 -0
- plain/signals/README.md +170 -1
- plain/signals/__init__.py +0 -1
- plain/signals/dispatch/dispatcher.py +49 -27
- plain/signing.py +131 -35
- plain/templates/README.md +211 -20
- plain/templates/jinja/__init__.py +13 -5
- plain/templates/jinja/environments.py +5 -4
- plain/templates/jinja/extensions.py +12 -5
- plain/templates/jinja/filters.py +7 -2
- plain/templates/jinja/globals.py +2 -2
- plain/test/README.md +184 -22
- plain/test/client.py +340 -222
- plain/test/encoding.py +9 -6
- plain/test/exceptions.py +7 -2
- plain/urls/README.md +157 -73
- plain/urls/converters.py +18 -15
- plain/urls/exceptions.py +2 -2
- plain/urls/patterns.py +38 -22
- plain/urls/resolvers.py +35 -25
- plain/urls/utils.py +5 -1
- plain/utils/README.md +250 -3
- plain/utils/cache.py +17 -11
- plain/utils/crypto.py +21 -5
- plain/utils/datastructures.py +89 -56
- plain/utils/dateparse.py +9 -6
- plain/utils/deconstruct.py +15 -7
- plain/utils/decorators.py +5 -1
- plain/utils/dotenv.py +373 -0
- plain/utils/duration.py +8 -4
- plain/utils/encoding.py +14 -7
- plain/utils/functional.py +66 -49
- plain/utils/hashable.py +5 -1
- plain/utils/html.py +36 -22
- plain/utils/http.py +16 -9
- plain/utils/inspect.py +14 -6
- plain/utils/ipv6.py +7 -3
- plain/utils/itercompat.py +6 -1
- plain/utils/module_loading.py +7 -3
- plain/utils/regex_helper.py +37 -23
- plain/utils/safestring.py +14 -6
- plain/utils/text.py +41 -23
- plain/utils/timezone.py +33 -22
- plain/utils/tree.py +35 -19
- plain/validators.py +94 -52
- plain/views/README.md +156 -79
- plain/views/__init__.py +0 -1
- plain/views/base.py +25 -18
- plain/views/errors.py +13 -5
- plain/views/exceptions.py +4 -1
- plain/views/forms.py +6 -6
- plain/views/objects.py +52 -49
- plain/views/redirect.py +18 -15
- plain/views/templates.py +5 -3
- plain/wsgi.py +3 -1
- {plain-0.68.0.dist-info → plain-0.103.0.dist-info}/METADATA +4 -2
- plain-0.103.0.dist-info/RECORD +198 -0
- {plain-0.68.0.dist-info → plain-0.103.0.dist-info}/WHEEL +1 -1
- plain-0.103.0.dist-info/entry_points.txt +2 -0
- plain/AGENTS.md +0 -18
- plain/cli/agent/__init__.py +0 -20
- plain/cli/agent/docs.py +0 -80
- plain/cli/agent/md.py +0 -87
- plain/cli/agent/prompt.py +0 -45
- plain/csrf/views.py +0 -31
- plain/logs/utils.py +0 -46
- plain/templates/AGENTS.md +0 -3
- plain-0.68.0.dist-info/RECORD +0 -169
- plain-0.68.0.dist-info/entry_points.txt +0 -5
- {plain-0.68.0.dist-info → plain-0.103.0.dist-info}/licenses/LICENSE +0 -0
plain/utils/module_loading.py
CHANGED
|
@@ -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
|
|
plain/utils/regex_helper.py
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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(*
|
|
201
|
+
return list(zip(*_flatten_result(result)))
|
|
194
202
|
|
|
195
203
|
|
|
196
|
-
def
|
|
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 =
|
|
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
|
|
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
|
|
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
|
-
|
|
270
|
-
if
|
|
271
|
-
|
|
272
|
-
return int(values[0]),
|
|
281
|
+
ch2 = None
|
|
282
|
+
if ch2 == "?":
|
|
283
|
+
ch2 = None
|
|
284
|
+
return int(values[0]), ch2
|
|
273
285
|
|
|
274
286
|
|
|
275
|
-
def
|
|
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
|
|
296
|
+
if _contains(elt, inst):
|
|
285
297
|
return True
|
|
286
298
|
return False
|
|
287
299
|
|
|
288
300
|
|
|
289
|
-
def
|
|
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 =
|
|
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(
|
|
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(
|
|
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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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 =
|
|
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 =
|
|
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(
|
|
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(
|
|
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__(
|
|
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(
|
|
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)
|