plain 0.83.0__py3-none-any.whl → 0.84.1__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.

Potentially problematic release.


This version of plain might be problematic. Click here for more details.

plain/CHANGELOG.md CHANGED
@@ -1,5 +1,43 @@
1
1
  # plain changelog
2
2
 
3
+ ## [0.84.1](https://github.com/dropseed/plain/releases/plain@0.84.1) (2025-10-31)
4
+
5
+ ### What's changed
6
+
7
+ - Added `license = "BSD-3-Clause"` to package metadata in `pyproject.toml` ([8477355](https://github.com/dropseed/plain/commit/8477355e65))
8
+
9
+ ### Upgrade instructions
10
+
11
+ - No changes required
12
+
13
+ ## [0.84.0](https://github.com/dropseed/plain/releases/plain@0.84.0) (2025-10-29)
14
+
15
+ ### What's changed
16
+
17
+ - The `DEFAULT_RESPONSE_HEADERS` setting now supports format string placeholders (e.g., `{request.csp_nonce}`) for dynamic header values instead of requiring a callable function ([5199383128](https://github.com/dropseed/plain/commit/5199383128))
18
+ - Views can now set headers to `None` to explicitly remove default response headers ([5199383128](https://github.com/dropseed/plain/commit/5199383128))
19
+ - Added comprehensive documentation for customizing default response headers including override, remove, and extend patterns ([5199383128](https://github.com/dropseed/plain/commit/5199383128))
20
+
21
+ ### Upgrade instructions
22
+
23
+ - If you have `DEFAULT_RESPONSE_HEADERS` configured as a callable function, convert it to a dictionary with format string placeholders:
24
+
25
+ ```python
26
+ # Before:
27
+ def DEFAULT_RESPONSE_HEADERS(request):
28
+ nonce = request.csp_nonce
29
+ return {
30
+ "Content-Security-Policy": f"script-src 'self' 'nonce-{nonce}'",
31
+ }
32
+
33
+ # After:
34
+ DEFAULT_RESPONSE_HEADERS = {
35
+ "Content-Security-Policy": "script-src 'self' 'nonce-{request.csp_nonce}'",
36
+ }
37
+ ```
38
+
39
+ - If you were overriding default headers to empty strings (`""`) to remove them, change those to `None` instead
40
+
3
41
  ## [0.83.0](https://github.com/dropseed/plain/releases/plain@0.83.0) (2025-10-29)
4
42
 
5
43
  ### What's changed
plain/http/README.md CHANGED
@@ -4,6 +4,7 @@
4
4
 
5
5
  - [Overview](#overview)
6
6
  - [Content Security Policy (CSP)](#content-security-policy-csp)
7
+ - [Customizing Default Response Headers](#customizing-default-response-headers)
7
8
 
8
9
  ## Overview
9
10
 
@@ -38,30 +39,29 @@ Each request generates a unique cryptographically secure nonce available via [`r
38
39
 
39
40
  ### Configuring CSP Headers
40
41
 
41
- Set `DEFAULT_RESPONSE_HEADERS` as a callable function to generate dynamic CSP headers with nonces:
42
+ Include CSP in `DEFAULT_RESPONSE_HEADERS` using `{request.csp_nonce}` placeholders for dynamic nonces:
42
43
 
43
44
  ```python
44
45
  # app/settings.py
45
- def DEFAULT_RESPONSE_HEADERS(request):
46
- """
47
- Dynamic response headers with CSP nonces.
48
- """
49
- nonce = request.csp_nonce
50
- return {
51
- "Content-Security-Policy": (
52
- f"default-src 'self'; "
53
- f"script-src 'self' 'nonce-{nonce}'; "
54
- f"style-src 'self' 'nonce-{nonce}'; "
55
- f"img-src 'self' data:; "
56
- f"font-src 'self'; "
57
- f"connect-src 'self'; "
58
- f"frame-ancestors 'self'; "
59
- f"base-uri 'self'; "
60
- f"form-action 'self'"
61
- ),
62
- }
46
+ DEFAULT_RESPONSE_HEADERS = {
47
+ "Content-Security-Policy": (
48
+ "default-src 'self'; "
49
+ "script-src 'self' 'nonce-{request.csp_nonce}'; "
50
+ "style-src 'self' 'nonce-{request.csp_nonce}'; "
51
+ "img-src 'self' data:; "
52
+ "font-src 'self'; "
53
+ "connect-src 'self'; "
54
+ "frame-ancestors 'self'; "
55
+ "base-uri 'self'; "
56
+ "form-action 'self'"
57
+ ),
58
+ # Other default headers...
59
+ "X-Frame-Options": "DENY",
60
+ }
63
61
  ```
64
62
 
63
+ The `{request.csp_nonce}` placeholder will be replaced with a unique nonce for each request.
64
+
65
65
  Use tools like [Google's CSP Evaluator](https://csp-evaluator.withgoogle.com/) to analyze your CSP policy and identify potential security issues or misconfigurations.
66
66
 
67
67
  ### Using Nonces in Templates
@@ -87,3 +87,56 @@ External scripts and stylesheets loaded from `'self'` don't need nonces:
87
87
  <script src="/assets/app.js"></script>
88
88
  <link rel="stylesheet" href="/assets/app.css">
89
89
  ```
90
+
91
+ ## Customizing Default Response Headers
92
+
93
+ Plain applies default response headers to all responses via `DEFAULT_RESPONSE_HEADERS` in settings. Views can customize these headers in several ways:
94
+
95
+ ### Override Default Headers
96
+
97
+ Set the header to a different value in your view:
98
+
99
+ ```python
100
+ class MyView(View):
101
+ def get(self):
102
+ response = Response("content")
103
+ # Override the default X-Frame-Options: DENY
104
+ response.headers["X-Frame-Options"] = "SAMEORIGIN"
105
+ return response
106
+ ```
107
+
108
+ ### Remove Default Headers
109
+
110
+ Set the header to `None` to prevent it from being applied:
111
+
112
+ ```python
113
+ class EmbeddableView(View):
114
+ def get(self):
115
+ response = Response("content")
116
+ # Remove X-Frame-Options entirely to allow embedding
117
+ response.headers["X-Frame-Options"] = None
118
+ return response
119
+ ```
120
+
121
+ ### Extend Default Headers
122
+
123
+ Read the default value from settings, modify it, then set it in your view:
124
+
125
+ ```python
126
+ from plain.runtime import settings
127
+
128
+ class MyView(View):
129
+ def get(self):
130
+ response = Response("content")
131
+
132
+ # Get the default CSP policy
133
+ if csp := settings.DEFAULT_RESPONSE_HEADERS.get("Content-Security-Policy"):
134
+ # Format it with the current request to resolve placeholders
135
+ csp = csp.format(request=self.request)
136
+ # Extend with additional sources
137
+ response.headers["Content-Security-Policy"] = (
138
+ f"{csp}; script-src https://analytics.example.com"
139
+ )
140
+
141
+ return response
142
+ ```
@@ -10,24 +10,46 @@ if TYPE_CHECKING:
10
10
 
11
11
 
12
12
  class DefaultHeadersMiddleware(HttpMiddleware):
13
+ """
14
+ Applies default response headers from settings.DEFAULT_RESPONSE_HEADERS.
15
+
16
+ This middleware runs after the view executes and applies default headers
17
+ to the response using setdefault(), which means:
18
+ - Headers already set by the view won't be overridden
19
+ - Headers not set by the view will use the default value
20
+
21
+ View Customization Patterns:
22
+ - Use default: Don't set the header (middleware applies it)
23
+ - Override: Set the header to a different value
24
+ - Remove: Set the header to None (middleware will delete it)
25
+ - Extend: Read from settings.DEFAULT_RESPONSE_HEADERS, modify, then set
26
+
27
+ Format Strings:
28
+ Header values can include {request.attribute} placeholders for dynamic
29
+ content. Example: 'nonce-{request.csp_nonce}' will be formatted with
30
+ the request's csp_nonce value. Headers without placeholders are used as-is.
31
+
32
+ None Removal:
33
+ Views can set a header to None to opt-out of that default header entirely.
34
+ The middleware will delete any header set to None, preventing the default
35
+ from being applied.
36
+ """
37
+
13
38
  def process_request(self, request: Request) -> Response:
39
+ # Get the response from the view (and any inner middleware)
14
40
  response = self.get_response(request)
15
41
 
16
- # Support callable DEFAULT_RESPONSE_HEADERS for dynamic header generation
17
- # (e.g., CSP nonces that change per request)
18
- if callable(settings.DEFAULT_RESPONSE_HEADERS):
19
- default_headers = settings.DEFAULT_RESPONSE_HEADERS(request)
20
- else:
21
- default_headers = settings.DEFAULT_RESPONSE_HEADERS
22
-
23
- for header, value in default_headers.items():
24
- # Since we don't have a good way to *remove* default response headers,
25
- # use allow users to set them to an empty string to indicate they should be removed.
26
- if header in response.headers and response.headers[header] == "":
42
+ # Apply default headers to the response
43
+ for header, value in settings.DEFAULT_RESPONSE_HEADERS.items():
44
+ if header not in response.headers:
45
+ # Header not set - apply default
46
+ if "{" in value:
47
+ response.headers[header] = value.format(request=request)
48
+ else:
49
+ response.headers[header] = value
50
+ elif response.headers[header] is None:
51
+ # Header explicitly set to None by view - remove it
27
52
  del response.headers[header]
28
- continue
29
-
30
- response.headers.setdefault(header, value)
31
53
 
32
54
  # Add the Content-Length header to non-streaming responses if not
33
55
  # already set.
@@ -27,8 +27,12 @@ URLS_ROUTER: str
27
27
  ALLOWED_HOSTS: list[str] = []
28
28
 
29
29
  # Default headers for all responses.
30
- DEFAULT_RESPONSE_HEADERS = {
31
- # "Content-Security-Policy": "default-src 'self'",
30
+ # Header values can include {request.attribute} placeholders for dynamic content.
31
+ # Example: "script-src 'nonce-{request.csp_nonce}'" will use the request's nonce.
32
+ # Views can override, remove, or extend these headers - see plain/http/README.md
33
+ # for customization patterns.
34
+ DEFAULT_RESPONSE_HEADERS: dict = {
35
+ # "Content-Security-Policy": "default-src 'self'; script-src 'self' 'nonce-{request.csp_nonce}'",
32
36
  # https://hstspreload.org/
33
37
  # "Strict-Transport-Security": "max-age=31536000; includeSubDomains; preload",
34
38
  "Cross-Origin-Opener-Policy": "same-origin",
@@ -1,8 +1,9 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: plain
3
- Version: 0.83.0
3
+ Version: 0.84.1
4
4
  Summary: A web framework for building products with Python.
5
5
  Author-email: Dave Gaeddert <dave.gaeddert@dropseed.dev>
6
+ License-Expression: BSD-3-Clause
6
7
  License-File: LICENSE
7
8
  Requires-Python: >=3.13
8
9
  Requires-Dist: click>=8.0.0
@@ -1,5 +1,5 @@
1
1
  plain/AGENTS.md,sha256=As6EFSWWHJ9lYIxb2LMRqNRteH45SRs7a_VFslzF53M,1046
2
- plain/CHANGELOG.md,sha256=xY-6RVqtfACU0z3588U3ALK_B3tuaNuBbp1d2J8qFf8,33715
2
+ plain/CHANGELOG.md,sha256=Deiduawr0bENQUV8aTvh-b-I8RlfBwsz0i0S3ibhYlE,35334
3
3
  plain/README.md,sha256=VvzhXNvf4I6ddmjBP9AExxxFXxs7RwyoxdgFm-W5dsg,4072
4
4
  plain/__main__.py,sha256=GK39854Lc_LO_JP8DzY9Y2MIQ4cQEl7SXFJy244-lC8,110
5
5
  plain/debug.py,sha256=C2OnFHtRGMrpCiHSt-P2r58JypgQZ62qzDBpV4mhtFM,855
@@ -56,7 +56,7 @@ plain/forms/boundfield.py,sha256=PEquPRn1BoVd4ZkIin8tjkBzRDMv-41wO8qHV0S1shg,196
56
56
  plain/forms/exceptions.py,sha256=MuMUF-33Qsc_HWk5zI3rcWzdvXzQbEOKAZvJKkbrL58,320
57
57
  plain/forms/fields.py,sha256=GQSTI6-eaHivIXj3TQSw2LpyWMbN0NZ-SHjUO_oxqeA,37132
58
58
  plain/forms/forms.py,sha256=GnJWzjIXOPByfrdiqjjo4xdKdkBw1gBD6Hq4mg73gHQ,11259
59
- plain/http/README.md,sha256=8FLsZ12Gx6lLa0mFMDcqWYetM6ysCvfHMZLLh4dAAVI,2611
59
+ plain/http/README.md,sha256=HP0m45CKhtA5Jmc65VhMZCYEH9ATK7BfBvkHwUASnwA,4221
60
60
  plain/http/__init__.py,sha256=gUTIGh-GbSIlh3SP-Db-XAOX4P_j2hh2445w8Cm-zKQ,1032
61
61
  plain/http/cookie.py,sha256=x13G3LIr0jxnPK1NQRptmi0DrAq9PsivQnQTm4LKaW0,2191
62
62
  plain/http/middleware.py,sha256=TPs585IIFjgp-5uUAJtIoigH6uwTS3FJqwFSsQdayd4,960
@@ -78,7 +78,7 @@ plain/internal/handlers/base.py,sha256=ZlpOYqd45X0wPYlmxHwXR5kyCkWBB6ZJeSGZka5VA
78
78
  plain/internal/handlers/exception.py,sha256=P7oTqVtKoIHBOxCml1BSgNBV_rQWyCHlO5hS87NVjXo,4872
79
79
  plain/internal/handlers/wsgi.py,sha256=d2Hcs4fzTSir6OvtpVromTw0RmmFAqTL4_EaBjoHtNU,8919
80
80
  plain/internal/middleware/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
81
- plain/internal/middleware/headers.py,sha256=z3rmjIGGYV5JcAfAUm3tcdG34OFoEfYukJrwZsq7JIo,1425
81
+ plain/internal/middleware/headers.py,sha256=fX8iMOeecqSyI7y-IIQ_w4OUFRJpgMvUr_xtUzI7IUQ,2361
82
82
  plain/internal/middleware/hosts.py,sha256=UXMts7cKA9ocOK-J8DAcZPbKYQtfVyLSLH2QQX3mKjM,5895
83
83
  plain/internal/middleware/https.py,sha256=8n990O0Ej5cRrBlfAnLW2nZ2mx0wn0002C4HuRhVpsM,1117
84
84
  plain/internal/middleware/slash.py,sha256=V_rTb9v8uMUR_N2TLETBwlJFItoL4WD9JnRyFaFs878,3073
@@ -102,7 +102,7 @@ plain/preflight/security.py,sha256=nMbflO2LN49oKAAaa-tYvuweNB0RWv1z3w9niU037n0,3
102
102
  plain/preflight/urls.py,sha256=Asw_vq-70NRqr15yuBAYL0JCZ04liumORYT3I3KmF_k,437
103
103
  plain/runtime/README.md,sha256=ZZ3NPTjtxwyfw1V827YNwkWc8MiH5rWiy27HrN13qZ8,4819
104
104
  plain/runtime/__init__.py,sha256=dvF5ipRckVf6LQgY4kdxE_dTlCdncuawQf3o5EqLq9k,2524
105
- plain/runtime/global_settings.py,sha256=3nYptasBjcodSR2Nq8Pm0KViPpvIS4SDOTShr5294Kk,5413
105
+ plain/runtime/global_settings.py,sha256=BVkLDWA2i4kbtjQabFdJ4cYhVrt-mU8UqZdBRz7cbwU,5741
106
106
  plain/runtime/user_settings.py,sha256=Tbd0J6bxp18tKmFsQcdlxeFhUQU68PYtsswzQ2IcfNc,11467
107
107
  plain/runtime/utils.py,sha256=sHOv9SWCalBtg32GtZofimM2XaQtf_jAyyf6RQuOlGc,851
108
108
  plain/server/LICENSE,sha256=Xt_dw4qYwQI9qSi2u8yMZeb4HuMRp5tESRKhtvvJBgA,1707
@@ -188,8 +188,8 @@ plain/views/forms.py,sha256=ESZOXuo6IeYixp1RZvPb94KplkowRiwO2eGJCM6zJI0,2400
188
188
  plain/views/objects.py,sha256=5y0PoPPo07dQTTcJ_9kZcx0iI1O7regsooAIK4VqXQ0,5579
189
189
  plain/views/redirect.py,sha256=mIpSAFcaEyeLDyv4Fr6g_ektduG4Wfa6s6L-rkdazmM,2154
190
190
  plain/views/templates.py,sha256=9LgDMCv4C7JzLiyw8jHF-i4350ukwgixC_9y4faEGu0,1885
191
- plain-0.83.0.dist-info/METADATA,sha256=hiux2R0QdqLHhIroLLNQdEyMh4ZnKnfzJod0bGsGNw0,4516
192
- plain-0.83.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
193
- plain-0.83.0.dist-info/entry_points.txt,sha256=1Ys2lsSeMepD1vz8RSrJopna0RQfUd951vYvCRsvl6A,45
194
- plain-0.83.0.dist-info/licenses/LICENSE,sha256=m0D5O7QoH9l5Vz_rrX_9r-C8d9UNr_ciK6Qwac7o6yo,3175
195
- plain-0.83.0.dist-info/RECORD,,
191
+ plain-0.84.1.dist-info/METADATA,sha256=2RLvl085caxyJc1npztwRGlbIfwyVGFmUxolnKJUyZI,4549
192
+ plain-0.84.1.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
193
+ plain-0.84.1.dist-info/entry_points.txt,sha256=1Ys2lsSeMepD1vz8RSrJopna0RQfUd951vYvCRsvl6A,45
194
+ plain-0.84.1.dist-info/licenses/LICENSE,sha256=m0D5O7QoH9l5Vz_rrX_9r-C8d9UNr_ciK6Qwac7o6yo,3175
195
+ plain-0.84.1.dist-info/RECORD,,
File without changes