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.
Files changed (192) hide show
  1. plain/CHANGELOG.md +684 -1
  2. plain/README.md +1 -1
  3. plain/agents/.claude/rules/plain.md +88 -0
  4. plain/agents/.claude/skills/plain-install/SKILL.md +26 -0
  5. plain/agents/.claude/skills/plain-upgrade/SKILL.md +35 -0
  6. plain/assets/compile.py +25 -12
  7. plain/assets/finders.py +24 -17
  8. plain/assets/fingerprints.py +10 -7
  9. plain/assets/urls.py +1 -1
  10. plain/assets/views.py +47 -33
  11. plain/chores/README.md +25 -23
  12. plain/chores/__init__.py +2 -1
  13. plain/chores/core.py +27 -0
  14. plain/chores/registry.py +23 -36
  15. plain/cli/README.md +185 -16
  16. plain/cli/__init__.py +2 -1
  17. plain/cli/agent.py +234 -0
  18. plain/cli/build.py +7 -8
  19. plain/cli/changelog.py +11 -5
  20. plain/cli/chores.py +32 -34
  21. plain/cli/core.py +110 -26
  22. plain/cli/docs.py +98 -21
  23. plain/cli/formatting.py +40 -17
  24. plain/cli/install.py +10 -54
  25. plain/cli/{agent/llmdocs.py → llmdocs.py} +45 -26
  26. plain/cli/output.py +6 -2
  27. plain/cli/preflight.py +27 -75
  28. plain/cli/print.py +4 -4
  29. plain/cli/registry.py +96 -10
  30. plain/cli/{agent/request.py → request.py} +67 -33
  31. plain/cli/runtime.py +45 -0
  32. plain/cli/scaffold.py +2 -7
  33. plain/cli/server.py +153 -0
  34. plain/cli/settings.py +53 -49
  35. plain/cli/shell.py +15 -12
  36. plain/cli/startup.py +9 -8
  37. plain/cli/upgrade.py +17 -104
  38. plain/cli/urls.py +12 -7
  39. plain/cli/utils.py +3 -3
  40. plain/csrf/README.md +65 -40
  41. plain/csrf/middleware.py +53 -43
  42. plain/debug.py +5 -2
  43. plain/exceptions.py +22 -114
  44. plain/forms/README.md +453 -24
  45. plain/forms/__init__.py +55 -4
  46. plain/forms/boundfield.py +15 -8
  47. plain/forms/exceptions.py +1 -1
  48. plain/forms/fields.py +346 -143
  49. plain/forms/forms.py +75 -45
  50. plain/http/README.md +356 -9
  51. plain/http/__init__.py +41 -26
  52. plain/http/cookie.py +15 -7
  53. plain/http/exceptions.py +65 -0
  54. plain/http/middleware.py +32 -0
  55. plain/http/multipartparser.py +99 -88
  56. plain/http/request.py +362 -250
  57. plain/http/response.py +99 -197
  58. plain/internal/__init__.py +8 -1
  59. plain/internal/files/base.py +35 -19
  60. plain/internal/files/locks.py +19 -11
  61. plain/internal/files/move.py +8 -3
  62. plain/internal/files/temp.py +25 -6
  63. plain/internal/files/uploadedfile.py +47 -28
  64. plain/internal/files/uploadhandler.py +64 -58
  65. plain/internal/files/utils.py +24 -10
  66. plain/internal/handlers/base.py +34 -23
  67. plain/internal/handlers/exception.py +68 -65
  68. plain/internal/handlers/wsgi.py +65 -54
  69. plain/internal/middleware/headers.py +37 -11
  70. plain/internal/middleware/hosts.py +11 -8
  71. plain/internal/middleware/https.py +17 -7
  72. plain/internal/middleware/slash.py +14 -9
  73. plain/internal/reloader.py +77 -0
  74. plain/json.py +2 -1
  75. plain/logs/README.md +161 -62
  76. plain/logs/__init__.py +1 -1
  77. plain/logs/{loggers.py → app.py} +71 -67
  78. plain/logs/configure.py +63 -14
  79. plain/logs/debug.py +17 -6
  80. plain/logs/filters.py +15 -0
  81. plain/logs/formatters.py +7 -4
  82. plain/packages/README.md +105 -23
  83. plain/packages/config.py +15 -7
  84. plain/packages/registry.py +27 -16
  85. plain/paginator.py +31 -21
  86. plain/preflight/README.md +209 -24
  87. plain/preflight/__init__.py +1 -0
  88. plain/preflight/checks.py +3 -1
  89. plain/preflight/files.py +3 -1
  90. plain/preflight/registry.py +26 -11
  91. plain/preflight/results.py +15 -7
  92. plain/preflight/security.py +15 -13
  93. plain/preflight/settings.py +54 -0
  94. plain/preflight/urls.py +4 -1
  95. plain/runtime/README.md +115 -47
  96. plain/runtime/__init__.py +10 -6
  97. plain/runtime/global_settings.py +34 -25
  98. plain/runtime/secret.py +20 -0
  99. plain/runtime/user_settings.py +110 -38
  100. plain/runtime/utils.py +1 -1
  101. plain/server/LICENSE +35 -0
  102. plain/server/README.md +155 -0
  103. plain/server/__init__.py +9 -0
  104. plain/server/app.py +52 -0
  105. plain/server/arbiter.py +555 -0
  106. plain/server/config.py +118 -0
  107. plain/server/errors.py +31 -0
  108. plain/server/glogging.py +292 -0
  109. plain/server/http/__init__.py +12 -0
  110. plain/server/http/body.py +283 -0
  111. plain/server/http/errors.py +155 -0
  112. plain/server/http/message.py +400 -0
  113. plain/server/http/parser.py +70 -0
  114. plain/server/http/unreader.py +88 -0
  115. plain/server/http/wsgi.py +421 -0
  116. plain/server/pidfile.py +92 -0
  117. plain/server/sock.py +240 -0
  118. plain/server/util.py +317 -0
  119. plain/server/workers/__init__.py +6 -0
  120. plain/server/workers/base.py +304 -0
  121. plain/server/workers/sync.py +212 -0
  122. plain/server/workers/thread.py +399 -0
  123. plain/server/workers/workertmp.py +50 -0
  124. plain/signals/README.md +170 -1
  125. plain/signals/__init__.py +0 -1
  126. plain/signals/dispatch/dispatcher.py +49 -27
  127. plain/signing.py +131 -35
  128. plain/templates/README.md +211 -20
  129. plain/templates/jinja/__init__.py +13 -5
  130. plain/templates/jinja/environments.py +5 -4
  131. plain/templates/jinja/extensions.py +12 -5
  132. plain/templates/jinja/filters.py +7 -2
  133. plain/templates/jinja/globals.py +2 -2
  134. plain/test/README.md +184 -22
  135. plain/test/client.py +340 -222
  136. plain/test/encoding.py +9 -6
  137. plain/test/exceptions.py +7 -2
  138. plain/urls/README.md +157 -73
  139. plain/urls/converters.py +18 -15
  140. plain/urls/exceptions.py +2 -2
  141. plain/urls/patterns.py +38 -22
  142. plain/urls/resolvers.py +35 -25
  143. plain/urls/utils.py +5 -1
  144. plain/utils/README.md +250 -3
  145. plain/utils/cache.py +17 -11
  146. plain/utils/crypto.py +21 -5
  147. plain/utils/datastructures.py +89 -56
  148. plain/utils/dateparse.py +9 -6
  149. plain/utils/deconstruct.py +15 -7
  150. plain/utils/decorators.py +5 -1
  151. plain/utils/dotenv.py +373 -0
  152. plain/utils/duration.py +8 -4
  153. plain/utils/encoding.py +14 -7
  154. plain/utils/functional.py +66 -49
  155. plain/utils/hashable.py +5 -1
  156. plain/utils/html.py +36 -22
  157. plain/utils/http.py +16 -9
  158. plain/utils/inspect.py +14 -6
  159. plain/utils/ipv6.py +7 -3
  160. plain/utils/itercompat.py +6 -1
  161. plain/utils/module_loading.py +7 -3
  162. plain/utils/regex_helper.py +37 -23
  163. plain/utils/safestring.py +14 -6
  164. plain/utils/text.py +41 -23
  165. plain/utils/timezone.py +33 -22
  166. plain/utils/tree.py +35 -19
  167. plain/validators.py +94 -52
  168. plain/views/README.md +156 -79
  169. plain/views/__init__.py +0 -1
  170. plain/views/base.py +25 -18
  171. plain/views/errors.py +13 -5
  172. plain/views/exceptions.py +4 -1
  173. plain/views/forms.py +6 -6
  174. plain/views/objects.py +52 -49
  175. plain/views/redirect.py +18 -15
  176. plain/views/templates.py +5 -3
  177. plain/wsgi.py +3 -1
  178. {plain-0.68.0.dist-info → plain-0.103.0.dist-info}/METADATA +4 -2
  179. plain-0.103.0.dist-info/RECORD +198 -0
  180. {plain-0.68.0.dist-info → plain-0.103.0.dist-info}/WHEEL +1 -1
  181. plain-0.103.0.dist-info/entry_points.txt +2 -0
  182. plain/AGENTS.md +0 -18
  183. plain/cli/agent/__init__.py +0 -20
  184. plain/cli/agent/docs.py +0 -80
  185. plain/cli/agent/md.py +0 -87
  186. plain/cli/agent/prompt.py +0 -45
  187. plain/csrf/views.py +0 -31
  188. plain/logs/utils.py +0 -46
  189. plain/templates/AGENTS.md +0 -3
  190. plain-0.68.0.dist-info/RECORD +0 -169
  191. plain-0.68.0.dist-info/entry_points.txt +0 -5
  192. {plain-0.68.0.dist-info → plain-0.103.0.dist-info}/licenses/LICENSE +0 -0
@@ -1,13 +1,23 @@
1
- import uuid
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 HttpRequest, QueryDict, parse_cookie
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=-1, /):
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=-1, /):
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 == -1 or size is None:
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(HttpRequest):
56
- non_picklable_attrs = HttpRequest.non_picklable_attrs | frozenset(["environ"])
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
- def __init__(self, environ):
60
- # A unique ID we can use to trace this request
61
- self.unique_id = str(uuid.uuid4())
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.meta = environ
76
- self.meta["PATH_INFO"] = path_info
77
- self.meta["SCRIPT_NAME"] = script_name
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
- # Set content_type, content_params, and encoding.
80
- self._set_content_type_params(environ)
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 self.meta_non_picklable_attrs:
92
- if attr in state["meta"]:
93
- del state["meta"][attr]
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._encoding)
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__(self, environ, start_response):
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
- *response.headers.items(),
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 getattr(response, "file_to_stream", None) is not None and environ.get(
146
- "wsgi.file_wrapper"
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
- class DefaultHeadersMiddleware:
5
- def __init__(self, get_response):
6
- self.get_response = get_response
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
- def __call__(self, request):
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
- # Since we don't have a good way to *remote* default response headers,
13
- # use allow users to set them to an empty string to indicate they should be removed.
14
- if header in response.headers and response.headers[header] == "":
15
- del response.headers[header]
16
- continue
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 HttpRequest, ResponseBadRequest
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 __init__(self, get_response):
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 ResponseBadRequest()
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: HttpRequest) -> bool:
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 plain.http import ResponseRedirect
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
- self.get_response = get_response
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 __call__(self, request):
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 ResponseRedirect(
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 plain.http import ResponseRedirect
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
- def __call__(self, request):
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 ResponseRedirect(
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()