plain 0.52.2__py3-none-any.whl → 0.53.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 +13 -0
- plain/cli/build.py +2 -5
- plain/cli/docs.py +3 -7
- plain/cli/preflight.py +1 -1
- plain/cli/urls.py +1 -4
- plain/http/cookie.py +44 -0
- plain/http/request.py +15 -0
- plain/http/response.py +5 -12
- plain/templates/jinja/filters.py +20 -0
- {plain-0.52.2.dist-info → plain-0.53.0.dist-info}/METADATA +1 -1
- {plain-0.52.2.dist-info → plain-0.53.0.dist-info}/RECORD +14 -14
- {plain-0.52.2.dist-info → plain-0.53.0.dist-info}/WHEEL +0 -0
- {plain-0.52.2.dist-info → plain-0.53.0.dist-info}/entry_points.txt +0 -0
- {plain-0.52.2.dist-info → plain-0.53.0.dist-info}/licenses/LICENSE +0 -0
plain/CHANGELOG.md
CHANGED
@@ -1,5 +1,18 @@
|
|
1
1
|
# plain changelog
|
2
2
|
|
3
|
+
## [0.53.0](https://github.com/dropseed/plain/releases/plain@0.53.0) (2025-07-18)
|
4
|
+
|
5
|
+
### What's changed
|
6
|
+
|
7
|
+
- Added a `pluralize` filter for Jinja templates to handle singular/plural forms ([4cef9829ed](https://github.com/dropseed/plain/commit/4cef9829ed))
|
8
|
+
- Added `get_signed_cookie()` method to `HttpRequest` for retrieving and verifying signed cookies ([f8796c8786](https://github.com/dropseed/plain/commit/f8796c8786))
|
9
|
+
- Improved CLI error handling by using `click.UsageError` instead of manual error printing ([88f06c5184](https://github.com/dropseed/plain/commit/88f06c5184))
|
10
|
+
- Simplified preflight check success message ([adffc06152](https://github.com/dropseed/plain/commit/adffc06152))
|
11
|
+
|
12
|
+
### Upgrade instructions
|
13
|
+
|
14
|
+
- No changes required
|
15
|
+
|
3
16
|
## [0.52.2](https://github.com/dropseed/plain/releases/plain@0.52.2) (2025-06-27)
|
4
17
|
|
5
18
|
### What's changed
|
plain/cli/build.py
CHANGED
@@ -37,12 +37,9 @@ def build(keep_original, fingerprint, compress):
|
|
37
37
|
"""Pre-deployment build step (compile assets, css, js, etc.)"""
|
38
38
|
|
39
39
|
if not keep_original and not fingerprint:
|
40
|
-
click.
|
41
|
-
"You must either keep the original assets or fingerprint them."
|
42
|
-
fg="red",
|
43
|
-
err=True,
|
40
|
+
raise click.UsageError(
|
41
|
+
"You must either keep the original assets or fingerprint them."
|
44
42
|
)
|
45
|
-
sys.exit(1)
|
46
43
|
|
47
44
|
# Run user-defined build commands first
|
48
45
|
pyproject_path = plain.runtime.APP_PATH.parent / "pyproject.toml"
|
plain/cli/docs.py
CHANGED
@@ -1,6 +1,5 @@
|
|
1
1
|
import ast
|
2
2
|
import importlib.util
|
3
|
-
import sys
|
4
3
|
from pathlib import Path
|
5
4
|
|
6
5
|
import click
|
@@ -16,8 +15,7 @@ from .output import iterate_markdown
|
|
16
15
|
@click.argument("module", default="")
|
17
16
|
def docs(module, llm, open):
|
18
17
|
if not module and not llm:
|
19
|
-
click.
|
20
|
-
sys.exit(1)
|
18
|
+
raise click.UsageError("You must specify a module or use --llm")
|
21
19
|
|
22
20
|
if llm:
|
23
21
|
paths = [Path(__file__).parent.parent]
|
@@ -49,14 +47,12 @@ def docs(module, llm, open):
|
|
49
47
|
# Get the README.md file for the module
|
50
48
|
spec = importlib.util.find_spec(module)
|
51
49
|
if not spec:
|
52
|
-
click.
|
53
|
-
sys.exit(1)
|
50
|
+
raise click.UsageError(f"Module {module} not found")
|
54
51
|
|
55
52
|
module_path = Path(spec.origin).parent
|
56
53
|
readme_path = module_path / "README.md"
|
57
54
|
if not readme_path.exists():
|
58
|
-
click.
|
59
|
-
sys.exit(1)
|
55
|
+
raise click.UsageError(f"README.md not found for {module}")
|
60
56
|
|
61
57
|
if open:
|
62
58
|
click.launch(str(readme_path))
|
plain/cli/preflight.py
CHANGED
@@ -123,4 +123,4 @@ def preflight_checks(package_label, deploy, fail_level, database):
|
|
123
123
|
msg = header + body + footer
|
124
124
|
click.echo(msg, err=True)
|
125
125
|
else:
|
126
|
-
click.secho("✔
|
126
|
+
click.secho("✔ Checks passed", err=True, fg="green")
|
plain/cli/urls.py
CHANGED
@@ -1,5 +1,3 @@
|
|
1
|
-
import sys
|
2
|
-
|
3
1
|
import click
|
4
2
|
|
5
3
|
|
@@ -17,8 +15,7 @@ def list_urls(flat):
|
|
17
15
|
from plain.urls import URLResolver, get_resolver
|
18
16
|
|
19
17
|
if not settings.URLS_ROUTER:
|
20
|
-
click.
|
21
|
-
sys.exit(1)
|
18
|
+
raise click.UsageError("URLS_ROUTER is not set")
|
22
19
|
|
23
20
|
resolver = get_resolver(settings.URLS_ROUTER)
|
24
21
|
if flat:
|
plain/http/cookie.py
CHANGED
@@ -1,5 +1,9 @@
|
|
1
1
|
from http import cookies
|
2
2
|
|
3
|
+
from plain.runtime import settings
|
4
|
+
from plain.signing import BadSignature, TimestampSigner
|
5
|
+
from plain.utils.encoding import force_bytes
|
6
|
+
|
3
7
|
|
4
8
|
def parse_cookie(cookie):
|
5
9
|
"""
|
@@ -18,3 +22,43 @@ def parse_cookie(cookie):
|
|
18
22
|
# unquote using Python's algorithm.
|
19
23
|
cookiedict[key] = cookies._unquote(val)
|
20
24
|
return cookiedict
|
25
|
+
|
26
|
+
|
27
|
+
def _cookie_key(key):
|
28
|
+
"""
|
29
|
+
Generate a key for cookie signing that matches the pattern used by
|
30
|
+
set_signed_cookie and get_signed_cookie.
|
31
|
+
"""
|
32
|
+
return b"plain.http.cookies" + force_bytes(key)
|
33
|
+
|
34
|
+
|
35
|
+
def get_signed_cookie_signer(key, salt=""):
|
36
|
+
"""
|
37
|
+
Create a TimestampSigner for signed cookies with the same configuration
|
38
|
+
used by both set_signed_cookie and get_signed_cookie.
|
39
|
+
"""
|
40
|
+
return TimestampSigner(
|
41
|
+
key=_cookie_key(settings.SECRET_KEY),
|
42
|
+
fallback_keys=map(_cookie_key, settings.SECRET_KEY_FALLBACKS),
|
43
|
+
salt=key + salt,
|
44
|
+
)
|
45
|
+
|
46
|
+
|
47
|
+
def sign_cookie_value(key, value, salt=""):
|
48
|
+
"""
|
49
|
+
Sign a cookie value using the standard Plain cookie signing approach.
|
50
|
+
"""
|
51
|
+
signer = get_signed_cookie_signer(key, salt)
|
52
|
+
return signer.sign(value)
|
53
|
+
|
54
|
+
|
55
|
+
def unsign_cookie_value(key, signed_value, salt="", max_age=None, default=None):
|
56
|
+
"""
|
57
|
+
Unsign a cookie value using the standard Plain cookie signing approach.
|
58
|
+
Returns the default value if the signature is invalid or the cookie has expired.
|
59
|
+
"""
|
60
|
+
signer = get_signed_cookie_signer(key, salt)
|
61
|
+
try:
|
62
|
+
return signer.unsign(signed_value, max_age=max_age)
|
63
|
+
except BadSignature:
|
64
|
+
return default
|
plain/http/request.py
CHANGED
@@ -13,6 +13,7 @@ from plain.exceptions import (
|
|
13
13
|
RequestDataTooBig,
|
14
14
|
TooManyFieldsSent,
|
15
15
|
)
|
16
|
+
from plain.http.cookie import unsign_cookie_value
|
16
17
|
from plain.http.multipartparser import (
|
17
18
|
MultiPartParser,
|
18
19
|
MultiPartParserError,
|
@@ -427,6 +428,20 @@ class HttpRequest:
|
|
427
428
|
def readlines(self):
|
428
429
|
return list(self)
|
429
430
|
|
431
|
+
def get_signed_cookie(self, key, default=None, salt="", max_age=None):
|
432
|
+
"""
|
433
|
+
Retrieve a cookie value signed with the SECRET_KEY.
|
434
|
+
|
435
|
+
Return default if the cookie doesn't exist or signature verification fails.
|
436
|
+
"""
|
437
|
+
|
438
|
+
try:
|
439
|
+
cookie_value = self.cookies[key]
|
440
|
+
except KeyError:
|
441
|
+
return default
|
442
|
+
|
443
|
+
return unsign_cookie_value(key, cookie_value, salt, max_age, default)
|
444
|
+
|
430
445
|
|
431
446
|
class HttpHeaders(CaseInsensitiveMapping):
|
432
447
|
HTTP_PREFIX = "HTTP_"
|
plain/http/response.py
CHANGED
@@ -12,13 +12,14 @@ from http.client import responses
|
|
12
12
|
from http.cookies import SimpleCookie
|
13
13
|
from urllib.parse import urlparse
|
14
14
|
|
15
|
-
from plain import signals
|
15
|
+
from plain import signals
|
16
16
|
from plain.exceptions import DisallowedRedirect
|
17
|
+
from plain.http.cookie import sign_cookie_value
|
17
18
|
from plain.json import PlainJSONEncoder
|
18
19
|
from plain.runtime import settings
|
19
20
|
from plain.utils import timezone
|
20
21
|
from plain.utils.datastructures import CaseInsensitiveMapping
|
21
|
-
from plain.utils.encoding import
|
22
|
+
from plain.utils.encoding import iri_to_uri
|
22
23
|
from plain.utils.http import content_disposition_header, http_date
|
23
24
|
from plain.utils.regex_helper import _lazy_re_compile
|
24
25
|
|
@@ -260,16 +261,8 @@ class ResponseBase:
|
|
260
261
|
def set_signed_cookie(self, key, value, salt="", **kwargs):
|
261
262
|
"""Set a cookie signed with the SECRET_KEY."""
|
262
263
|
|
263
|
-
|
264
|
-
|
265
|
-
|
266
|
-
signer = signing.TimestampSigner(
|
267
|
-
key=_cookie_key(settings.SECRET_KEY),
|
268
|
-
fallback_keys=map(_cookie_key, settings.SECRET_KEY_FALLBACKS),
|
269
|
-
salt=key + salt,
|
270
|
-
)
|
271
|
-
value = signer.sign(value)
|
272
|
-
return self.set_cookie(key, value, **kwargs)
|
264
|
+
signed_value = sign_cookie_value(key, value, salt)
|
265
|
+
return self.set_cookie(key, signed_value, **kwargs)
|
273
266
|
|
274
267
|
def delete_cookie(self, key, path="/", domain=None, samesite=None):
|
275
268
|
# Browsers can ignore the Set-Cookie header if the cookie doesn't use
|
plain/templates/jinja/filters.py
CHANGED
@@ -15,6 +15,25 @@ def localtime_filter(value, timezone=None):
|
|
15
15
|
return localtime(value, timezone)
|
16
16
|
|
17
17
|
|
18
|
+
def pluralize_filter(value, singular="", plural="s"):
|
19
|
+
"""Returns plural suffix based on the value count.
|
20
|
+
|
21
|
+
Usage:
|
22
|
+
{{ count }} item{{ count|pluralize }}
|
23
|
+
{{ count }} ox{{ count|pluralize("en") }}
|
24
|
+
{{ count }} cact{{ count|pluralize("us","i") }}
|
25
|
+
"""
|
26
|
+
try:
|
27
|
+
count = int(value)
|
28
|
+
except (ValueError, TypeError):
|
29
|
+
return singular
|
30
|
+
|
31
|
+
if count == 1:
|
32
|
+
return singular
|
33
|
+
|
34
|
+
return plural
|
35
|
+
|
36
|
+
|
18
37
|
default_filters = {
|
19
38
|
# The standard Python ones
|
20
39
|
"strftime": datetime.datetime.strftime,
|
@@ -27,4 +46,5 @@ default_filters = {
|
|
27
46
|
"timesince": timesince,
|
28
47
|
"json_script": json_script,
|
29
48
|
"islice": islice, # slice for dict.items()
|
49
|
+
"pluralize": pluralize_filter,
|
30
50
|
}
|
@@ -1,4 +1,4 @@
|
|
1
|
-
plain/CHANGELOG.md,sha256=
|
1
|
+
plain/CHANGELOG.md,sha256=DHF0llr5trZE_8C0138MnuFQEAM9W2jM9nL0xfjjz8Y,4472
|
2
2
|
plain/README.md,sha256=gik6DBZcJAITcm4WRq_L53AxkjY45eQLafyTCSf0CKE,3986
|
3
3
|
plain/__main__.py,sha256=GK39854Lc_LO_JP8DzY9Y2MIQ4cQEl7SXFJy244-lC8,110
|
4
4
|
plain/debug.py,sha256=XdjnXcbPGsi0J2SpHGaLthhYU5AjhBlkHdemaP4sbYY,758
|
@@ -20,22 +20,22 @@ plain/chores/__init__.py,sha256=r9TXtQCH-VbvfnIJ5F8FxgQC35GRWFOfmMZN3q9niLg,67
|
|
20
20
|
plain/chores/registry.py,sha256=V3WjuekRI22LFvJbqSkUXQtiOtuE2ZK8gKV1TRvxRUI,1866
|
21
21
|
plain/cli/README.md,sha256=GzBry6mEilhM80SfVUg02ydGwAk0m-s6FAqQR1nRsMM,2022
|
22
22
|
plain/cli/__init__.py,sha256=6w9T7K2WrPwh6DcaMb2oNt_CWU6Bc57nUTO2Bt1p38Y,63
|
23
|
-
plain/cli/build.py,sha256=
|
23
|
+
plain/cli/build.py,sha256=Lo6AYghJz0DM9fIVUSiBSOKa5vR0XCOxZWEjza6sc8Q,3172
|
24
24
|
plain/cli/changelog.py,sha256=j-k1yZk9mpm-fLZgeWastiyIisxNSuAJfXTQ2B6WQmk,3457
|
25
25
|
plain/cli/chores.py,sha256=xXSSFvr8T5jWfLWqe6E8YVMw1BkQxyOHHVuY0x9RH0A,2412
|
26
26
|
plain/cli/core.py,sha256=D3t83ujjjHayblM-RuttrGoNf8hMV9-l3zQsbhVAjWU,2991
|
27
|
-
plain/cli/docs.py,sha256=
|
27
|
+
plain/cli/docs.py,sha256=5-2_nQnInZAzHu3VnMW88gZyrhukhdjrkMKTMt0RRpI,7367
|
28
28
|
plain/cli/formatting.py,sha256=1hZH13y1qwHcU2K2_Na388nw9uvoeQH8LrWL-O9h8Yc,2207
|
29
29
|
plain/cli/help.py,sha256=NefZSEIixrX_WELVSnJDHRpLDWf7_4PXmkkMm3Q2mzo,787
|
30
30
|
plain/cli/output.py,sha256=Fe3xS6Va4Bi1ZNrqi0nh09THTsdCyMW2b9SPY5I4n-o,1318
|
31
|
-
plain/cli/preflight.py,sha256=
|
31
|
+
plain/cli/preflight.py,sha256=8tHBD4L4nPLUKThfaYx3SUZSJzC48oV2m_Hbn6W4ODc,4124
|
32
32
|
plain/cli/print.py,sha256=XraUYrgODOJquIiEv78wSCYGRBplHXtXSS9QtFG5hqY,217
|
33
33
|
plain/cli/registry.py,sha256=yKVMSDjW8g10nlV9sPXFGJQmhC_U-k4J4kM7N2OQVLA,1467
|
34
34
|
plain/cli/scaffold.py,sha256=mcywA9DzfwoBSqWl5-Zpgcy1mTNUGEgdvoxXUrGcEVk,1351
|
35
35
|
plain/cli/settings.py,sha256=9cx4bue664I2P7kUedlf4YhCPB0tSKSE4Q8mGyzEv2o,1995
|
36
36
|
plain/cli/shell.py,sha256=iIwvlTdTBjLBBUdXMAmIRWSoynszOZI79-mrBg4RegU,1373
|
37
37
|
plain/cli/startup.py,sha256=wLaFuyUb4ewWhtehBCGicrRCXIIGCRbeCT3ce9hUv-A,1022
|
38
|
-
plain/cli/urls.py,sha256=
|
38
|
+
plain/cli/urls.py,sha256=ghCW36aRszxmTo06A50FIvYopb6kQ07QekkDzM6_A1o,3824
|
39
39
|
plain/cli/utils.py,sha256=VwlIh0z7XxzVV8I3qM2kZo07fkJFPoeeVZa1ODG616k,258
|
40
40
|
plain/csrf/README.md,sha256=nxCpPk1HF5eAM-7paxg9D-9RVCU9jXsSPAVHkJvA_DU,717
|
41
41
|
plain/csrf/middleware.py,sha256=U3B9R7ciO_UAf7O3jdNtVu6QZ_3Yrm8isRdnW6bVKX4,17401
|
@@ -48,10 +48,10 @@ plain/forms/fields.py,sha256=OyL4eZIgJ_XMLPHGar17hLepFmwHV-hSnb_n7s18yUU,34709
|
|
48
48
|
plain/forms/forms.py,sha256=hF7Dl8rEaiBTZhFQyfbh1Zf54BSEka8RYpBiGqkTa8I,10441
|
49
49
|
plain/http/README.md,sha256=F9wbahgSU3jIDEG14gJjdPJRem4weUNvwnwhb7o3cu0,722
|
50
50
|
plain/http/__init__.py,sha256=DIsDRbBsCGa4qZgq-fUuQS0kkxfbTU_3KpIM9VvH04w,1067
|
51
|
-
plain/http/cookie.py,sha256=
|
51
|
+
plain/http/cookie.py,sha256=THd7nOl-2ugeBPKgOhbD87aM2oxUbNH8HWrarUn0fpM,1955
|
52
52
|
plain/http/multipartparser.py,sha256=Z1dFJNAd8N5RHUuF67jh1jBfZOFepORsre_3ee6CgOQ,27266
|
53
|
-
plain/http/request.py,sha256=
|
54
|
-
plain/http/response.py,sha256=
|
53
|
+
plain/http/request.py,sha256=93b2gqkfEsBczUyP_9vlueVoxyzzfbnJ423PDAk8aHc,26103
|
54
|
+
plain/http/response.py,sha256=0xUhkTiT6JwohdwA7ymY2vpdCQVl4hnEExjk01LrJbg,23734
|
55
55
|
plain/internal/__init__.py,sha256=fVBaYLCXEQc-7riHMSlw3vMTTuF7-0Bj2I8aGzv0o0w,171
|
56
56
|
plain/internal/files/__init__.py,sha256=VctFgox4Q1AWF3klPaoCC5GIw5KeLafYjY5JmN8mAVw,63
|
57
57
|
plain/internal/files/base.py,sha256=2z19tik2_xgXlI6nfVZ4woSF9WB0RSUzsvOfi1Bz8Wg,4113
|
@@ -100,7 +100,7 @@ plain/templates/core.py,sha256=iw58EAmyyv8N5HDA-Sq4-fLgz_qx8v8WJfurgR116jw,625
|
|
100
100
|
plain/templates/jinja/__init__.py,sha256=xvYif0feMYR9pWjN0czthq2dk3qI4D5UQjgj9yp4dNA,2776
|
101
101
|
plain/templates/jinja/environments.py,sha256=9plifzvQj--aTN1cCpJ2WdzQxZJpzB8S_4hghgQRQT0,2064
|
102
102
|
plain/templates/jinja/extensions.py,sha256=AEmmmHDbdRW8fhjYDzq9eSSNbp9WHsXenD8tPthjc0s,1351
|
103
|
-
plain/templates/jinja/filters.py,sha256=
|
103
|
+
plain/templates/jinja/filters.py,sha256=ft5XUr4OLeQayn-MSxrycpFLyyN_yEo7j5WhWMwpTOs,1445
|
104
104
|
plain/templates/jinja/globals.py,sha256=VMpuMZvwWOmb5MbzKK-ox-QEX_WSsXFxq0mm8biJgaU,558
|
105
105
|
plain/test/README.md,sha256=fv4YzziU2QxgcNHSgv7aDUO45sDOofVuCNrV1NPbWzo,1106
|
106
106
|
plain/test/__init__.py,sha256=MhNHtp7MYBl9kq-pMRGY11kJ6kU1I6vOkjNkit1TYRg,94
|
@@ -149,8 +149,8 @@ plain/views/forms.py,sha256=ESZOXuo6IeYixp1RZvPb94KplkowRiwO2eGJCM6zJI0,2400
|
|
149
149
|
plain/views/objects.py,sha256=GGbcfg_9fPZ-PiaBwIHG2e__8GfWDR7JQtQ15wTyiHg,5970
|
150
150
|
plain/views/redirect.py,sha256=daq2cQIkdDF78bt43sjuZxRAyJm_t_SKw6tyPmiXPIc,1985
|
151
151
|
plain/views/templates.py,sha256=ivkI7LU7BXDQ0d4Geab96Is4-Cp03KbIntXRT1J8e6I,2139
|
152
|
-
plain-0.
|
153
|
-
plain-0.
|
154
|
-
plain-0.
|
155
|
-
plain-0.
|
156
|
-
plain-0.
|
152
|
+
plain-0.53.0.dist-info/METADATA,sha256=n3gNCFOwyioHwL_hKBiAmIGxSQG6CBM-XjM-x5EMq5I,4297
|
153
|
+
plain-0.53.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
154
|
+
plain-0.53.0.dist-info/entry_points.txt,sha256=nn4uKTRRZuEKOJv3810s3jtSMW0Gew7XDYiKIvBRR6M,93
|
155
|
+
plain-0.53.0.dist-info/licenses/LICENSE,sha256=m0D5O7QoH9l5Vz_rrX_9r-C8d9UNr_ciK6Qwac7o6yo,3175
|
156
|
+
plain-0.53.0.dist-info/RECORD,,
|
File without changes
|
File without changes
|
File without changes
|