plain 0.68.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.
- plain/CHANGELOG.md +656 -1
- plain/README.md +1 -1
- 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 +236 -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 +52 -11
- plain/cli/formatting.py +40 -17
- plain/cli/install.py +10 -54
- plain/cli/{agent/llmdocs.py → llmdocs.py} +21 -9
- 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/skills/README.md +36 -0
- plain/skills/plain-docs/SKILL.md +25 -0
- plain/skills/plain-install/SKILL.md +26 -0
- plain/skills/plain-request/SKILL.md +39 -0
- plain/skills/plain-shell/SKILL.md +24 -0
- plain/skills/plain-upgrade/SKILL.md +35 -0
- 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.101.2.dist-info}/METADATA +4 -2
- plain-0.101.2.dist-info/RECORD +201 -0
- {plain-0.68.0.dist-info → plain-0.101.2.dist-info}/WHEEL +1 -1
- plain-0.101.2.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.101.2.dist-info}/licenses/LICENSE +0 -0
plain/internal/handlers/wsgi.py
CHANGED
|
@@ -1,13 +1,23 @@
|
|
|
1
|
-
import
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import codecs
|
|
2
4
|
from functools import cached_property
|
|
3
5
|
from io import IOBase
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
4
7
|
from urllib.parse import quote
|
|
5
8
|
|
|
6
9
|
from plain import signals
|
|
7
|
-
from plain.http import
|
|
10
|
+
from plain.http import FileResponse, QueryDict, Request, parse_cookie
|
|
8
11
|
from plain.internal.handlers import base
|
|
12
|
+
from plain.utils.http import parse_header_parameters
|
|
9
13
|
from plain.utils.regex_helper import _lazy_re_compile
|
|
10
14
|
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from collections.abc import Callable, Iterable
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from plain.http import ResponseBase
|
|
20
|
+
|
|
11
21
|
_slashes_re = _lazy_re_compile(rb"/+")
|
|
12
22
|
|
|
13
23
|
|
|
@@ -19,13 +29,13 @@ class LimitedStream(IOBase):
|
|
|
19
29
|
See https://github.com/pallets/werkzeug/blob/dbf78f67/src/werkzeug/wsgi.py#L828
|
|
20
30
|
"""
|
|
21
31
|
|
|
22
|
-
def __init__(self, stream, limit):
|
|
32
|
+
def __init__(self, stream: Any, limit: int) -> None:
|
|
23
33
|
self._read = stream.read
|
|
24
34
|
self._readline = stream.readline
|
|
25
35
|
self._pos = 0
|
|
26
36
|
self.limit = limit
|
|
27
37
|
|
|
28
|
-
def read(self, size
|
|
38
|
+
def read(self, size: int = -1, /) -> bytes:
|
|
29
39
|
_pos = self._pos
|
|
30
40
|
limit = self.limit
|
|
31
41
|
if _pos >= limit:
|
|
@@ -38,12 +48,12 @@ class LimitedStream(IOBase):
|
|
|
38
48
|
self._pos += len(data)
|
|
39
49
|
return data
|
|
40
50
|
|
|
41
|
-
def readline(self, size
|
|
51
|
+
def readline(self, size: int | None = -1, /) -> bytes:
|
|
42
52
|
_pos = self._pos
|
|
43
53
|
limit = self.limit
|
|
44
54
|
if _pos >= limit:
|
|
45
55
|
return b""
|
|
46
|
-
if size
|
|
56
|
+
if size is None or size == -1:
|
|
47
57
|
size = limit - _pos
|
|
48
58
|
else:
|
|
49
59
|
size = min(size, limit - _pos)
|
|
@@ -52,13 +62,13 @@ class LimitedStream(IOBase):
|
|
|
52
62
|
return line
|
|
53
63
|
|
|
54
64
|
|
|
55
|
-
class WSGIRequest(
|
|
56
|
-
non_picklable_attrs =
|
|
57
|
-
meta_non_picklable_attrs = frozenset(["wsgi.errors", "wsgi.input"])
|
|
65
|
+
class WSGIRequest(Request):
|
|
66
|
+
non_picklable_attrs = Request.non_picklable_attrs | frozenset(["environ"])
|
|
58
67
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
68
|
+
method: str # Always set from environ, overrides Request.method: str | None
|
|
69
|
+
|
|
70
|
+
def __init__(self, environ: dict[str, Any]) -> None:
|
|
71
|
+
super().__init__()
|
|
62
72
|
|
|
63
73
|
script_name = get_script_name(environ)
|
|
64
74
|
# If PATH_INFO is empty (e.g. accessing the SCRIPT_NAME URL without a
|
|
@@ -72,64 +82,62 @@ class WSGIRequest(HttpRequest):
|
|
|
72
82
|
self.path = "{}/{}".format(
|
|
73
83
|
script_name.rstrip("/"), path_info.replace("/", "", 1)
|
|
74
84
|
)
|
|
75
|
-
self.
|
|
76
|
-
self.
|
|
77
|
-
self.
|
|
85
|
+
self.environ = environ
|
|
86
|
+
self.environ["PATH_INFO"] = path_info
|
|
87
|
+
self.environ["SCRIPT_NAME"] = script_name
|
|
78
88
|
self.method = environ["REQUEST_METHOD"].upper()
|
|
79
|
-
|
|
80
|
-
|
|
89
|
+
|
|
90
|
+
# Set content_type, content_params, and encoding
|
|
91
|
+
self.content_type, self.content_params = parse_header_parameters(
|
|
92
|
+
environ.get("CONTENT_TYPE", "")
|
|
93
|
+
)
|
|
94
|
+
if "charset" in self.content_params:
|
|
95
|
+
try:
|
|
96
|
+
codecs.lookup(self.content_params["charset"])
|
|
97
|
+
except LookupError:
|
|
98
|
+
pass
|
|
99
|
+
else:
|
|
100
|
+
self.encoding = self.content_params["charset"]
|
|
101
|
+
|
|
81
102
|
try:
|
|
82
|
-
content_length = int(environ.get("CONTENT_LENGTH"))
|
|
103
|
+
content_length = int(environ.get("CONTENT_LENGTH") or 0)
|
|
83
104
|
except (ValueError, TypeError):
|
|
84
105
|
content_length = 0
|
|
85
106
|
self._stream = LimitedStream(self.environ["wsgi.input"], content_length)
|
|
86
107
|
self._read_started = False
|
|
87
|
-
self.resolver_match = None
|
|
88
108
|
|
|
89
|
-
def __getstate__(self):
|
|
109
|
+
def __getstate__(self) -> dict[str, Any]:
|
|
90
110
|
state = super().__getstate__()
|
|
91
|
-
for attr in
|
|
92
|
-
if attr in state["
|
|
93
|
-
del state["
|
|
111
|
+
for attr in frozenset(["wsgi.errors", "wsgi.input"]):
|
|
112
|
+
if attr in state["environ"]:
|
|
113
|
+
del state["environ"][attr]
|
|
94
114
|
return state
|
|
95
115
|
|
|
96
|
-
def _get_scheme(self):
|
|
97
|
-
return self.environ.get("wsgi.url_scheme")
|
|
116
|
+
def _get_scheme(self) -> str:
|
|
117
|
+
return self.environ.get("wsgi.url_scheme", "http")
|
|
98
118
|
|
|
99
119
|
@cached_property
|
|
100
|
-
def query_params(self):
|
|
120
|
+
def query_params(self) -> QueryDict:
|
|
101
121
|
# The WSGI spec says 'QUERY_STRING' may be absent.
|
|
102
122
|
raw_query_string = get_bytes_from_wsgi(self.environ, "QUERY_STRING", "")
|
|
103
|
-
return QueryDict(raw_query_string, encoding=self.
|
|
104
|
-
|
|
105
|
-
def _get_data(self):
|
|
106
|
-
if not hasattr(self, "_data"):
|
|
107
|
-
self._load_data_and_files()
|
|
108
|
-
return self._data
|
|
109
|
-
|
|
110
|
-
def _set_data(self, data):
|
|
111
|
-
self._data = data
|
|
123
|
+
return QueryDict(raw_query_string, encoding=self.encoding)
|
|
112
124
|
|
|
113
125
|
@cached_property
|
|
114
|
-
def cookies(self):
|
|
126
|
+
def cookies(self) -> dict[str, str]:
|
|
115
127
|
raw_cookie = get_str_from_wsgi(self.environ, "HTTP_COOKIE", "")
|
|
116
128
|
return parse_cookie(raw_cookie)
|
|
117
129
|
|
|
118
|
-
@property
|
|
119
|
-
def files(self):
|
|
120
|
-
if not hasattr(self, "_files"):
|
|
121
|
-
self._load_data_and_files()
|
|
122
|
-
return self._files
|
|
123
|
-
|
|
124
|
-
data = property(_get_data, _set_data)
|
|
125
|
-
|
|
126
130
|
|
|
127
131
|
class WSGIHandler(base.BaseHandler):
|
|
128
|
-
def __init__(self, *args, **kwargs):
|
|
132
|
+
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
|
129
133
|
super().__init__(*args, **kwargs)
|
|
130
134
|
self.load_middleware()
|
|
131
135
|
|
|
132
|
-
def __call__(
|
|
136
|
+
def __call__(
|
|
137
|
+
self,
|
|
138
|
+
environ: dict[str, Any],
|
|
139
|
+
start_response: Callable[[str, list[tuple[str, str]]], Any],
|
|
140
|
+
) -> ResponseBase | Iterable[bytes]:
|
|
133
141
|
signals.request_started.send(sender=self.__class__, environ=environ)
|
|
134
142
|
request = WSGIRequest(environ)
|
|
135
143
|
response = self.get_response(request)
|
|
@@ -138,12 +146,15 @@ class WSGIHandler(base.BaseHandler):
|
|
|
138
146
|
|
|
139
147
|
status = "%d %s" % (response.status_code, response.reason_phrase) # noqa: UP031
|
|
140
148
|
response_headers = [
|
|
141
|
-
|
|
149
|
+
# Filter out None values (used to opt-out of default headers)
|
|
150
|
+
*((k, v) for k, v in response.headers.items() if v is not None),
|
|
142
151
|
*(("Set-Cookie", c.output(header="")) for c in response.cookies.values()),
|
|
143
152
|
]
|
|
144
153
|
start_response(status, response_headers)
|
|
145
|
-
if
|
|
146
|
-
|
|
154
|
+
if (
|
|
155
|
+
isinstance(response, FileResponse)
|
|
156
|
+
and response.file_to_stream is not None
|
|
157
|
+
and environ.get("wsgi.file_wrapper")
|
|
147
158
|
):
|
|
148
159
|
# If `wsgi.file_wrapper` is used the WSGI server does not call
|
|
149
160
|
# .close on the response, but on the file wrapper. Patch it to use
|
|
@@ -155,11 +166,11 @@ class WSGIHandler(base.BaseHandler):
|
|
|
155
166
|
return response
|
|
156
167
|
|
|
157
168
|
|
|
158
|
-
def get_path_info(environ):
|
|
169
|
+
def get_path_info(environ: dict[str, Any]) -> str:
|
|
159
170
|
"""Return the HTTP request's PATH_INFO as a string."""
|
|
160
171
|
path_info = get_bytes_from_wsgi(environ, "PATH_INFO", "/")
|
|
161
172
|
|
|
162
|
-
def repercent_broken_unicode(path):
|
|
173
|
+
def repercent_broken_unicode(path: bytes) -> bytes:
|
|
163
174
|
"""
|
|
164
175
|
As per RFC 3987 Section 3.2, step three of converting a URI into an IRI,
|
|
165
176
|
repercent-encode any octet produced that is not part of a strictly legal
|
|
@@ -179,7 +190,7 @@ def get_path_info(environ):
|
|
|
179
190
|
return repercent_broken_unicode(path_info).decode()
|
|
180
191
|
|
|
181
192
|
|
|
182
|
-
def get_script_name(environ):
|
|
193
|
+
def get_script_name(environ: dict[str, Any]) -> str:
|
|
183
194
|
"""
|
|
184
195
|
Return the equivalent of the HTTP request's SCRIPT_NAME environment
|
|
185
196
|
variable. If Apache mod_rewrite is used, return what would have been
|
|
@@ -208,7 +219,7 @@ def get_script_name(environ):
|
|
|
208
219
|
return script_name.decode()
|
|
209
220
|
|
|
210
221
|
|
|
211
|
-
def get_bytes_from_wsgi(environ, key, default):
|
|
222
|
+
def get_bytes_from_wsgi(environ: dict[str, Any], key: str, default: str) -> bytes:
|
|
212
223
|
"""
|
|
213
224
|
Get a value from the WSGI environ dictionary as bytes.
|
|
214
225
|
|
|
@@ -221,7 +232,7 @@ def get_bytes_from_wsgi(environ, key, default):
|
|
|
221
232
|
return value.encode("iso-8859-1")
|
|
222
233
|
|
|
223
234
|
|
|
224
|
-
def get_str_from_wsgi(environ, key, default):
|
|
235
|
+
def get_str_from_wsgi(environ: dict[str, Any], key: str, default: str) -> str:
|
|
225
236
|
"""
|
|
226
237
|
Get a value from the WSGI environ dictionary as str.
|
|
227
238
|
|
|
@@ -1,21 +1,47 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING
|
|
4
|
+
|
|
5
|
+
from plain.http import HttpMiddleware
|
|
1
6
|
from plain.runtime import settings
|
|
2
7
|
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from plain.http import Request, Response
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class DefaultHeadersMiddleware(HttpMiddleware):
|
|
13
|
+
"""
|
|
14
|
+
Applies default response headers from settings.DEFAULT_RESPONSE_HEADERS.
|
|
3
15
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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
|
|
7
20
|
|
|
8
|
-
|
|
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 (not serialized in the response)
|
|
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
|
+
|
|
33
|
+
def process_request(self, request: Request) -> Response:
|
|
34
|
+
# Get the response from the view (and any inner middleware)
|
|
9
35
|
response = self.get_response(request)
|
|
10
36
|
|
|
37
|
+
# Apply default headers to the response
|
|
11
38
|
for header, value in settings.DEFAULT_RESPONSE_HEADERS.items():
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
response.headers.setdefault(header, value)
|
|
39
|
+
if header not in response.headers:
|
|
40
|
+
# Header not set - apply default
|
|
41
|
+
if "{" in value:
|
|
42
|
+
response.headers[header] = value.format(request=request)
|
|
43
|
+
else:
|
|
44
|
+
response.headers[header] = value
|
|
19
45
|
|
|
20
46
|
# Add the Content-Length header to non-streaming responses if not
|
|
21
47
|
# already set.
|
|
@@ -1,10 +1,16 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
1
3
|
import ipaddress
|
|
2
4
|
import logging
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
3
6
|
|
|
4
|
-
from plain.http import
|
|
7
|
+
from plain.http import HttpMiddleware, Request, Response
|
|
5
8
|
from plain.runtime import settings
|
|
6
9
|
from plain.utils.regex_helper import _lazy_re_compile
|
|
7
10
|
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from plain.http import Response
|
|
13
|
+
|
|
8
14
|
logger = logging.getLogger(__name__)
|
|
9
15
|
|
|
10
16
|
host_validation_re = _lazy_re_compile(
|
|
@@ -12,7 +18,7 @@ host_validation_re = _lazy_re_compile(
|
|
|
12
18
|
)
|
|
13
19
|
|
|
14
20
|
|
|
15
|
-
class HostValidationMiddleware:
|
|
21
|
+
class HostValidationMiddleware(HttpMiddleware):
|
|
16
22
|
"""
|
|
17
23
|
Middleware to validate the Host header against ALLOWED_HOSTS.
|
|
18
24
|
|
|
@@ -21,10 +27,7 @@ class HostValidationMiddleware:
|
|
|
21
27
|
host is not allowed.
|
|
22
28
|
"""
|
|
23
29
|
|
|
24
|
-
def
|
|
25
|
-
self.get_response = get_response
|
|
26
|
-
|
|
27
|
-
def __call__(self, request):
|
|
30
|
+
def process_request(self, request: Request) -> Response:
|
|
28
31
|
if not is_host_valid(request):
|
|
29
32
|
host = request.host
|
|
30
33
|
msg = f"Invalid HTTP_HOST header: {host!r}."
|
|
@@ -42,12 +45,12 @@ class HostValidationMiddleware:
|
|
|
42
45
|
extra={"status_code": 400, "request": request},
|
|
43
46
|
)
|
|
44
47
|
|
|
45
|
-
return
|
|
48
|
+
return Response(status_code=400)
|
|
46
49
|
|
|
47
50
|
return self.get_response(request)
|
|
48
51
|
|
|
49
52
|
|
|
50
|
-
def is_host_valid(request:
|
|
53
|
+
def is_host_valid(request: Request) -> bool:
|
|
51
54
|
"""
|
|
52
55
|
Check if the host is valid according to ALLOWED_HOSTS settings.
|
|
53
56
|
"""
|
|
@@ -1,15 +1,24 @@
|
|
|
1
|
-
from
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING
|
|
4
|
+
|
|
5
|
+
from plain.http import HttpMiddleware, RedirectResponse
|
|
2
6
|
from plain.runtime import settings
|
|
3
7
|
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from collections.abc import Callable
|
|
10
|
+
|
|
11
|
+
from plain.http import Request, Response
|
|
12
|
+
|
|
4
13
|
|
|
5
|
-
class HttpsRedirectMiddleware:
|
|
6
|
-
def __init__(self, get_response):
|
|
7
|
-
|
|
14
|
+
class HttpsRedirectMiddleware(HttpMiddleware):
|
|
15
|
+
def __init__(self, get_response: Callable[[Request], Response]):
|
|
16
|
+
super().__init__(get_response)
|
|
8
17
|
|
|
9
18
|
# Settings for HTTPS
|
|
10
19
|
self.https_redirect_enabled = settings.HTTPS_REDIRECT_ENABLED
|
|
11
20
|
|
|
12
|
-
def
|
|
21
|
+
def process_request(self, request: Request) -> Response:
|
|
13
22
|
"""
|
|
14
23
|
Perform a blanket HTTP→HTTPS redirect when enabled.
|
|
15
24
|
"""
|
|
@@ -19,8 +28,9 @@ class HttpsRedirectMiddleware:
|
|
|
19
28
|
|
|
20
29
|
return self.get_response(request)
|
|
21
30
|
|
|
22
|
-
def maybe_https_redirect(self, request):
|
|
31
|
+
def maybe_https_redirect(self, request: Request) -> Response | None:
|
|
23
32
|
if self.https_redirect_enabled and not request.is_https():
|
|
24
|
-
return
|
|
33
|
+
return RedirectResponse(
|
|
25
34
|
f"https://{request.host}{request.get_full_path()}", status_code=301
|
|
26
35
|
)
|
|
36
|
+
return None
|
|
@@ -1,14 +1,19 @@
|
|
|
1
|
-
from
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING
|
|
4
|
+
|
|
5
|
+
from plain.http import HttpMiddleware, RedirectResponse
|
|
2
6
|
from plain.runtime import settings
|
|
3
7
|
from plain.urls import Resolver404, get_resolver
|
|
4
8
|
from plain.utils.http import escape_leading_slashes
|
|
5
9
|
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from plain.http import Request, Response
|
|
12
|
+
from plain.urls import ResolverMatch
|
|
6
13
|
|
|
7
|
-
class RedirectSlashMiddleware:
|
|
8
|
-
def __init__(self, get_response):
|
|
9
|
-
self.get_response = get_response
|
|
10
14
|
|
|
11
|
-
|
|
15
|
+
class RedirectSlashMiddleware(HttpMiddleware):
|
|
16
|
+
def process_request(self, request: Request) -> Response:
|
|
12
17
|
"""
|
|
13
18
|
Rewrite the URL based on settings.APPEND_SLASH
|
|
14
19
|
"""
|
|
@@ -22,14 +27,14 @@ class RedirectSlashMiddleware:
|
|
|
22
27
|
# If the given URL is "Not Found", then check if we should redirect to
|
|
23
28
|
# a path with a slash appended.
|
|
24
29
|
if response.status_code == 404 and self.should_redirect_with_slash(request):
|
|
25
|
-
return
|
|
30
|
+
return RedirectResponse(
|
|
26
31
|
self.get_full_path_with_slash(request), status_code=301
|
|
27
32
|
)
|
|
28
33
|
|
|
29
34
|
return response
|
|
30
35
|
|
|
31
36
|
@staticmethod
|
|
32
|
-
def _is_valid_path(path):
|
|
37
|
+
def _is_valid_path(path: str) -> ResolverMatch | bool:
|
|
33
38
|
"""
|
|
34
39
|
Return the ResolverMatch if the given path resolves against the default URL
|
|
35
40
|
resolver, False otherwise. This is a convenience method to make working
|
|
@@ -40,7 +45,7 @@ class RedirectSlashMiddleware:
|
|
|
40
45
|
except Resolver404:
|
|
41
46
|
return False
|
|
42
47
|
|
|
43
|
-
def should_redirect_with_slash(self, request):
|
|
48
|
+
def should_redirect_with_slash(self, request: Request) -> ResolverMatch | bool:
|
|
44
49
|
"""
|
|
45
50
|
Return True if settings.APPEND_SLASH is True and appending a slash to
|
|
46
51
|
the request path turns an invalid path into a valid one.
|
|
@@ -50,7 +55,7 @@ class RedirectSlashMiddleware:
|
|
|
50
55
|
return self._is_valid_path(f"{request.path_info}/")
|
|
51
56
|
return False
|
|
52
57
|
|
|
53
|
-
def get_full_path_with_slash(self, request):
|
|
58
|
+
def get_full_path_with_slash(self, request: Request) -> str:
|
|
54
59
|
"""
|
|
55
60
|
Return the full path of the request with a trailing slash appended.
|
|
56
61
|
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import os.path
|
|
5
|
+
import re
|
|
6
|
+
import sys
|
|
7
|
+
import threading
|
|
8
|
+
from collections.abc import Callable
|
|
9
|
+
|
|
10
|
+
import watchfiles
|
|
11
|
+
|
|
12
|
+
COMPILED_EXT_RE = re.compile(r"py[co]$")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Reloader(threading.Thread):
|
|
16
|
+
"""File change reloader using watchfiles for cross-platform native file watching."""
|
|
17
|
+
|
|
18
|
+
def __init__(self, callback: Callable[[str], None], watch_html: bool) -> None:
|
|
19
|
+
super().__init__()
|
|
20
|
+
self.daemon = True
|
|
21
|
+
self._callback = callback
|
|
22
|
+
self._watch_html = watch_html
|
|
23
|
+
|
|
24
|
+
def get_watch_paths(self) -> set[str]:
|
|
25
|
+
"""Get all directories to watch for changes."""
|
|
26
|
+
paths = set()
|
|
27
|
+
|
|
28
|
+
# Get directories from loaded Python modules
|
|
29
|
+
for module in tuple(sys.modules.values()):
|
|
30
|
+
if not hasattr(module, "__file__") or not module.__file__:
|
|
31
|
+
continue
|
|
32
|
+
# Convert .pyc/.pyo to .py and get directory
|
|
33
|
+
file_path = COMPILED_EXT_RE.sub("py", module.__file__)
|
|
34
|
+
dir_path = os.path.dirname(os.path.abspath(file_path))
|
|
35
|
+
if os.path.isdir(dir_path):
|
|
36
|
+
paths.add(dir_path)
|
|
37
|
+
|
|
38
|
+
# Add current working directory for .env files
|
|
39
|
+
cwd = os.getcwd()
|
|
40
|
+
if os.path.isdir(cwd):
|
|
41
|
+
paths.add(cwd)
|
|
42
|
+
|
|
43
|
+
return paths
|
|
44
|
+
|
|
45
|
+
def run(self) -> None:
|
|
46
|
+
"""Watch for file changes and trigger callback."""
|
|
47
|
+
watch_paths = self.get_watch_paths()
|
|
48
|
+
|
|
49
|
+
for changes in watchfiles.watch(*watch_paths, rust_timeout=1000):
|
|
50
|
+
for change_type, file_path in changes:
|
|
51
|
+
should_reload = False
|
|
52
|
+
filename = os.path.basename(file_path)
|
|
53
|
+
|
|
54
|
+
# Python files: reload on modify/add
|
|
55
|
+
if change_type in (watchfiles.Change.modified, watchfiles.Change.added):
|
|
56
|
+
if file_path.endswith(".py"):
|
|
57
|
+
should_reload = True
|
|
58
|
+
|
|
59
|
+
# .env files: reload on modify/add/delete
|
|
60
|
+
if change_type in (
|
|
61
|
+
watchfiles.Change.modified,
|
|
62
|
+
watchfiles.Change.added,
|
|
63
|
+
watchfiles.Change.deleted,
|
|
64
|
+
):
|
|
65
|
+
if filename.startswith(".env"):
|
|
66
|
+
should_reload = True
|
|
67
|
+
|
|
68
|
+
# HTML files: only reload on add/delete (Jinja auto-reloads modifications)
|
|
69
|
+
if self._watch_html and change_type in (
|
|
70
|
+
watchfiles.Change.added,
|
|
71
|
+
watchfiles.Change.deleted,
|
|
72
|
+
):
|
|
73
|
+
if file_path.endswith(".html"):
|
|
74
|
+
should_reload = True
|
|
75
|
+
|
|
76
|
+
if should_reload:
|
|
77
|
+
self._callback(file_path)
|
plain/json.py
CHANGED
|
@@ -2,6 +2,7 @@ import datetime
|
|
|
2
2
|
import decimal
|
|
3
3
|
import json
|
|
4
4
|
import uuid
|
|
5
|
+
from typing import Any
|
|
5
6
|
|
|
6
7
|
from plain.utils.duration import duration_iso_string
|
|
7
8
|
from plain.utils.functional import Promise
|
|
@@ -14,7 +15,7 @@ class PlainJSONEncoder(json.JSONEncoder):
|
|
|
14
15
|
UUIDs.
|
|
15
16
|
"""
|
|
16
17
|
|
|
17
|
-
def default(self, o):
|
|
18
|
+
def default(self, o: Any) -> Any:
|
|
18
19
|
# See "Date Time String Format" in the ECMA-262 specification.
|
|
19
20
|
if isinstance(o, datetime.datetime):
|
|
20
21
|
r = o.isoformat()
|