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 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.secho(
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.secho("You must specify a module or use --llm", fg="red")
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.secho(f"Module {module} not found", fg="red")
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.secho(f"README.md not found for {module}", fg="red")
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("✔ Preflight check identified no issues.", err=True, fg="green")
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.secho("URLS_ROUTER is not set", fg="red")
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, signing
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 force_bytes, iri_to_uri
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
- def _cookie_key(k):
264
- return b"plain.http.cookies" + force_bytes(k)
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
@@ -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,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: plain
3
- Version: 0.52.2
3
+ Version: 0.53.0
4
4
  Summary: A web framework for building products with Python.
5
5
  Author-email: Dave Gaeddert <dave.gaeddert@dropseed.dev>
6
6
  License-File: LICENSE
@@ -1,4 +1,4 @@
1
- plain/CHANGELOG.md,sha256=gfkKcBoXisHose_cDs9fcJb_YFzcvKw8eB4c42mmfiw,3732
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=dKUYBNegvb6QNckR7XZ7CJJtINwZcmDvbUdv2dWwjf8,3226
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=KCJCP_OVFb34zOkA6x7X6-iGFzx2tv4ZgXAM99TjWNg,7443
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=FWFwMZ0W_t8ObTTRMnBmaiGN8PqdEAWgmSEPGDwZFpA,4148
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=7FOvLjfV1GsYKnb7SGlIgEfchQcrkWdYU1nY6aazGBI,3855
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=11FnSG3Plo6T3jZDbPoCw7SKh9ExdBio3pTmIO03URg,597
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=DbnC_E-PeiLM9pVJdcO869BtAU2gniLflMnPAOrrKU8,25618
54
- plain/http/response.py,sha256=WvVxQgQMq9X8YRBa9_neowfP3mx3TzN90SShSulfFQo,23970
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=t_u8BkWtEpJFLbLywONxWKrenSzOvDJhfOLwlZiXHDU,968
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.52.2.dist-info/METADATA,sha256=rNNWQbg6FlIJB8mElbHG2ZVPjylzN3pOP7ukO-meXlg,4297
153
- plain-0.52.2.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
154
- plain-0.52.2.dist-info/entry_points.txt,sha256=nn4uKTRRZuEKOJv3810s3jtSMW0Gew7XDYiKIvBRR6M,93
155
- plain-0.52.2.dist-info/licenses/LICENSE,sha256=m0D5O7QoH9l5Vz_rrX_9r-C8d9UNr_ciK6Qwac7o6yo,3175
156
- plain-0.52.2.dist-info/RECORD,,
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