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.
Files changed (195) hide show
  1. plain/CHANGELOG.md +656 -1
  2. plain/README.md +1 -1
  3. plain/assets/compile.py +25 -12
  4. plain/assets/finders.py +24 -17
  5. plain/assets/fingerprints.py +10 -7
  6. plain/assets/urls.py +1 -1
  7. plain/assets/views.py +47 -33
  8. plain/chores/README.md +25 -23
  9. plain/chores/__init__.py +2 -1
  10. plain/chores/core.py +27 -0
  11. plain/chores/registry.py +23 -36
  12. plain/cli/README.md +185 -16
  13. plain/cli/__init__.py +2 -1
  14. plain/cli/agent.py +236 -0
  15. plain/cli/build.py +7 -8
  16. plain/cli/changelog.py +11 -5
  17. plain/cli/chores.py +32 -34
  18. plain/cli/core.py +110 -26
  19. plain/cli/docs.py +52 -11
  20. plain/cli/formatting.py +40 -17
  21. plain/cli/install.py +10 -54
  22. plain/cli/{agent/llmdocs.py → llmdocs.py} +21 -9
  23. plain/cli/output.py +6 -2
  24. plain/cli/preflight.py +27 -75
  25. plain/cli/print.py +4 -4
  26. plain/cli/registry.py +96 -10
  27. plain/cli/{agent/request.py → request.py} +67 -33
  28. plain/cli/runtime.py +45 -0
  29. plain/cli/scaffold.py +2 -7
  30. plain/cli/server.py +153 -0
  31. plain/cli/settings.py +53 -49
  32. plain/cli/shell.py +15 -12
  33. plain/cli/startup.py +9 -8
  34. plain/cli/upgrade.py +17 -104
  35. plain/cli/urls.py +12 -7
  36. plain/cli/utils.py +3 -3
  37. plain/csrf/README.md +65 -40
  38. plain/csrf/middleware.py +53 -43
  39. plain/debug.py +5 -2
  40. plain/exceptions.py +22 -114
  41. plain/forms/README.md +453 -24
  42. plain/forms/__init__.py +55 -4
  43. plain/forms/boundfield.py +15 -8
  44. plain/forms/exceptions.py +1 -1
  45. plain/forms/fields.py +346 -143
  46. plain/forms/forms.py +75 -45
  47. plain/http/README.md +356 -9
  48. plain/http/__init__.py +41 -26
  49. plain/http/cookie.py +15 -7
  50. plain/http/exceptions.py +65 -0
  51. plain/http/middleware.py +32 -0
  52. plain/http/multipartparser.py +99 -88
  53. plain/http/request.py +362 -250
  54. plain/http/response.py +99 -197
  55. plain/internal/__init__.py +8 -1
  56. plain/internal/files/base.py +35 -19
  57. plain/internal/files/locks.py +19 -11
  58. plain/internal/files/move.py +8 -3
  59. plain/internal/files/temp.py +25 -6
  60. plain/internal/files/uploadedfile.py +47 -28
  61. plain/internal/files/uploadhandler.py +64 -58
  62. plain/internal/files/utils.py +24 -10
  63. plain/internal/handlers/base.py +34 -23
  64. plain/internal/handlers/exception.py +68 -65
  65. plain/internal/handlers/wsgi.py +65 -54
  66. plain/internal/middleware/headers.py +37 -11
  67. plain/internal/middleware/hosts.py +11 -8
  68. plain/internal/middleware/https.py +17 -7
  69. plain/internal/middleware/slash.py +14 -9
  70. plain/internal/reloader.py +77 -0
  71. plain/json.py +2 -1
  72. plain/logs/README.md +161 -62
  73. plain/logs/__init__.py +1 -1
  74. plain/logs/{loggers.py → app.py} +71 -67
  75. plain/logs/configure.py +63 -14
  76. plain/logs/debug.py +17 -6
  77. plain/logs/filters.py +15 -0
  78. plain/logs/formatters.py +7 -4
  79. plain/packages/README.md +105 -23
  80. plain/packages/config.py +15 -7
  81. plain/packages/registry.py +27 -16
  82. plain/paginator.py +31 -21
  83. plain/preflight/README.md +209 -24
  84. plain/preflight/__init__.py +1 -0
  85. plain/preflight/checks.py +3 -1
  86. plain/preflight/files.py +3 -1
  87. plain/preflight/registry.py +26 -11
  88. plain/preflight/results.py +15 -7
  89. plain/preflight/security.py +15 -13
  90. plain/preflight/settings.py +54 -0
  91. plain/preflight/urls.py +4 -1
  92. plain/runtime/README.md +115 -47
  93. plain/runtime/__init__.py +10 -6
  94. plain/runtime/global_settings.py +34 -25
  95. plain/runtime/secret.py +20 -0
  96. plain/runtime/user_settings.py +110 -38
  97. plain/runtime/utils.py +1 -1
  98. plain/server/LICENSE +35 -0
  99. plain/server/README.md +155 -0
  100. plain/server/__init__.py +9 -0
  101. plain/server/app.py +52 -0
  102. plain/server/arbiter.py +555 -0
  103. plain/server/config.py +118 -0
  104. plain/server/errors.py +31 -0
  105. plain/server/glogging.py +292 -0
  106. plain/server/http/__init__.py +12 -0
  107. plain/server/http/body.py +283 -0
  108. plain/server/http/errors.py +155 -0
  109. plain/server/http/message.py +400 -0
  110. plain/server/http/parser.py +70 -0
  111. plain/server/http/unreader.py +88 -0
  112. plain/server/http/wsgi.py +421 -0
  113. plain/server/pidfile.py +92 -0
  114. plain/server/sock.py +240 -0
  115. plain/server/util.py +317 -0
  116. plain/server/workers/__init__.py +6 -0
  117. plain/server/workers/base.py +304 -0
  118. plain/server/workers/sync.py +212 -0
  119. plain/server/workers/thread.py +399 -0
  120. plain/server/workers/workertmp.py +50 -0
  121. plain/signals/README.md +170 -1
  122. plain/signals/__init__.py +0 -1
  123. plain/signals/dispatch/dispatcher.py +49 -27
  124. plain/signing.py +131 -35
  125. plain/skills/README.md +36 -0
  126. plain/skills/plain-docs/SKILL.md +25 -0
  127. plain/skills/plain-install/SKILL.md +26 -0
  128. plain/skills/plain-request/SKILL.md +39 -0
  129. plain/skills/plain-shell/SKILL.md +24 -0
  130. plain/skills/plain-upgrade/SKILL.md +35 -0
  131. plain/templates/README.md +211 -20
  132. plain/templates/jinja/__init__.py +13 -5
  133. plain/templates/jinja/environments.py +5 -4
  134. plain/templates/jinja/extensions.py +12 -5
  135. plain/templates/jinja/filters.py +7 -2
  136. plain/templates/jinja/globals.py +2 -2
  137. plain/test/README.md +184 -22
  138. plain/test/client.py +340 -222
  139. plain/test/encoding.py +9 -6
  140. plain/test/exceptions.py +7 -2
  141. plain/urls/README.md +157 -73
  142. plain/urls/converters.py +18 -15
  143. plain/urls/exceptions.py +2 -2
  144. plain/urls/patterns.py +38 -22
  145. plain/urls/resolvers.py +35 -25
  146. plain/urls/utils.py +5 -1
  147. plain/utils/README.md +250 -3
  148. plain/utils/cache.py +17 -11
  149. plain/utils/crypto.py +21 -5
  150. plain/utils/datastructures.py +89 -56
  151. plain/utils/dateparse.py +9 -6
  152. plain/utils/deconstruct.py +15 -7
  153. plain/utils/decorators.py +5 -1
  154. plain/utils/dotenv.py +373 -0
  155. plain/utils/duration.py +8 -4
  156. plain/utils/encoding.py +14 -7
  157. plain/utils/functional.py +66 -49
  158. plain/utils/hashable.py +5 -1
  159. plain/utils/html.py +36 -22
  160. plain/utils/http.py +16 -9
  161. plain/utils/inspect.py +14 -6
  162. plain/utils/ipv6.py +7 -3
  163. plain/utils/itercompat.py +6 -1
  164. plain/utils/module_loading.py +7 -3
  165. plain/utils/regex_helper.py +37 -23
  166. plain/utils/safestring.py +14 -6
  167. plain/utils/text.py +41 -23
  168. plain/utils/timezone.py +33 -22
  169. plain/utils/tree.py +35 -19
  170. plain/validators.py +94 -52
  171. plain/views/README.md +156 -79
  172. plain/views/__init__.py +0 -1
  173. plain/views/base.py +25 -18
  174. plain/views/errors.py +13 -5
  175. plain/views/exceptions.py +4 -1
  176. plain/views/forms.py +6 -6
  177. plain/views/objects.py +52 -49
  178. plain/views/redirect.py +18 -15
  179. plain/views/templates.py +5 -3
  180. plain/wsgi.py +3 -1
  181. {plain-0.68.0.dist-info → plain-0.101.2.dist-info}/METADATA +4 -2
  182. plain-0.101.2.dist-info/RECORD +201 -0
  183. {plain-0.68.0.dist-info → plain-0.101.2.dist-info}/WHEEL +1 -1
  184. plain-0.101.2.dist-info/entry_points.txt +2 -0
  185. plain/AGENTS.md +0 -18
  186. plain/cli/agent/__init__.py +0 -20
  187. plain/cli/agent/docs.py +0 -80
  188. plain/cli/agent/md.py +0 -87
  189. plain/cli/agent/prompt.py +0 -45
  190. plain/csrf/views.py +0 -31
  191. plain/logs/utils.py +0 -46
  192. plain/templates/AGENTS.md +0 -3
  193. plain-0.68.0.dist-info/RECORD +0 -169
  194. plain-0.68.0.dist-info/entry_points.txt +0 -5
  195. {plain-0.68.0.dist-info → plain-0.101.2.dist-info}/licenses/LICENSE +0 -0
plain/urls/resolvers.py CHANGED
@@ -6,31 +6,39 @@ a string) and returns a ResolverMatch object which provides access to all
6
6
  attributes of the resolved URL match.
7
7
  """
8
8
 
9
+ from __future__ import annotations
10
+
9
11
  import functools
10
12
  import re
11
13
  from threading import local
14
+ from typing import TYPE_CHECKING, Any
12
15
  from urllib.parse import quote
13
16
 
14
17
  from plain.runtime import settings
15
18
  from plain.utils.datastructures import MultiValueDict
16
19
  from plain.utils.http import RFC3986_SUBDELIMS, escape_leading_slashes
17
20
  from plain.utils.module_loading import import_string
18
- from plain.utils.regex_helper import normalize
21
+ from plain.utils.regex_helper import _normalize
19
22
 
20
23
  from .exceptions import NoReverseMatch, Resolver404
21
- from .patterns import RegexPattern, URLPattern
24
+ from .patterns import RegexPattern, RoutePattern, URLPattern
25
+
26
+ if TYPE_CHECKING:
27
+ from plain.preflight import PreflightResult
28
+
29
+ from .routers import Router
22
30
 
23
31
 
24
32
  class ResolverMatch:
25
33
  def __init__(
26
34
  self,
27
35
  *,
28
- view,
29
- args,
30
- kwargs,
31
- url_name=None,
32
- namespaces=None,
33
- route=None,
36
+ view: Any,
37
+ args: tuple[Any, ...],
38
+ kwargs: dict[str, Any],
39
+ url_name: str | None = None,
40
+ namespaces: list[str] | None = None,
41
+ route: str | None = None,
34
42
  ):
35
43
  self.view = view
36
44
  self.args = args
@@ -48,7 +56,7 @@ class ResolverMatch:
48
56
  )
49
57
 
50
58
 
51
- def get_resolver(router=None):
59
+ def get_resolver(router: str | Router | None = None) -> URLResolver:
52
60
  if router is None:
53
61
  router = settings.URLS_ROUTER
54
62
 
@@ -56,7 +64,7 @@ def get_resolver(router=None):
56
64
 
57
65
 
58
66
  @functools.cache
59
- def _get_cached_resolver(router):
67
+ def _get_cached_resolver(router: str | Router) -> URLResolver:
60
68
  if isinstance(router, str):
61
69
  # Do this inside the cached call, primarily for the URLS_ROUTER
62
70
  router_class = import_string(router)
@@ -66,7 +74,9 @@ def _get_cached_resolver(router):
66
74
 
67
75
 
68
76
  @functools.cache
69
- def get_ns_resolver(ns_pattern, resolver, converters):
77
+ def get_ns_resolver(
78
+ ns_pattern: str, resolver: URLResolver, converters: tuple[tuple[str, Any], ...]
79
+ ) -> URLResolver:
70
80
  from .routers import Router
71
81
 
72
82
  # Build a namespaced resolver for the given parent urls_module pattern.
@@ -95,13 +105,13 @@ class URLResolver:
95
105
  def __init__(
96
106
  self,
97
107
  *,
98
- pattern,
99
- router,
108
+ pattern: RegexPattern | RoutePattern,
109
+ router: Router,
100
110
  ):
101
111
  self.pattern = pattern
102
112
  self.router = router
103
- self._reverse_dict = {}
104
- self._namespace_dict = {}
113
+ self._reverse_dict: MultiValueDict = MultiValueDict()
114
+ self._namespace_dict: dict[str, tuple[str, URLResolver]] = {}
105
115
  self._populated = False
106
116
  self._local = local()
107
117
 
@@ -110,17 +120,17 @@ class URLResolver:
110
120
  self.namespace = self.router.namespace
111
121
  self.url_patterns = self.router.urls
112
122
 
113
- def __repr__(self):
123
+ def __repr__(self) -> str:
114
124
  return f"<{self.__class__.__name__} {repr(self.router)} ({self.namespace}) {self.pattern.describe()}>"
115
125
 
116
- def preflight(self):
126
+ def preflight(self) -> list[PreflightResult]:
117
127
  messages = []
118
128
  messages.extend(self.pattern.preflight())
119
129
  for pattern in self.url_patterns:
120
130
  messages.extend(pattern.preflight())
121
131
  return messages
122
132
 
123
- def _populate(self):
133
+ def _populate(self) -> None:
124
134
  # Short-circuit if called recursively in this thread to prevent
125
135
  # infinite recursion. Concurrent threads may call this at the same
126
136
  # time and will need to continue, so set 'populating' on a
@@ -135,7 +145,7 @@ class URLResolver:
135
145
  p_pattern = url_pattern.pattern.regex.pattern
136
146
  p_pattern = p_pattern.removeprefix("^")
137
147
  if isinstance(url_pattern, URLPattern):
138
- bits = normalize(url_pattern.pattern.regex.pattern)
148
+ bits = _normalize(url_pattern.pattern.regex.pattern)
139
149
  lookups.appendlist(
140
150
  url_pattern.view,
141
151
  (
@@ -164,7 +174,7 @@ class URLResolver:
164
174
  pat,
165
175
  converters,
166
176
  ) in url_pattern.reverse_dict.getlist(name):
167
- new_matches = normalize(p_pattern + pat)
177
+ new_matches = _normalize(p_pattern + pat)
168
178
  lookups.appendlist(
169
179
  name,
170
180
  (
@@ -191,26 +201,26 @@ class URLResolver:
191
201
  self._local.populating = False
192
202
 
193
203
  @property
194
- def reverse_dict(self):
204
+ def reverse_dict(self) -> MultiValueDict:
195
205
  if not self._reverse_dict:
196
206
  self._populate()
197
207
  return self._reverse_dict
198
208
 
199
209
  @property
200
- def namespace_dict(self):
210
+ def namespace_dict(self) -> dict[str, tuple[str, URLResolver]]:
201
211
  if not self._namespace_dict:
202
212
  self._populate()
203
213
  return self._namespace_dict
204
214
 
205
215
  @staticmethod
206
- def _join_route(route1, route2):
216
+ def _join_route(route1: str, route2: str) -> str:
207
217
  """Join two routes, without the starting ^ in the second route."""
208
218
  if not route1:
209
219
  return route2
210
220
  route2 = route2.removeprefix("^")
211
221
  return route1 + route2
212
222
 
213
- def resolve(self, path):
223
+ def resolve(self, path: str) -> ResolverMatch:
214
224
  path = str(path) # path may be a reverse_lazy object
215
225
  match = self.pattern.match(path)
216
226
  if match:
@@ -247,7 +257,7 @@ class URLResolver:
247
257
  raise Resolver404({"path": new_path})
248
258
  raise Resolver404({"path": path})
249
259
 
250
- def reverse(self, lookup_view, *args, **kwargs):
260
+ def reverse(self, lookup_view: Any, *args: Any, **kwargs: Any) -> str:
251
261
  if args and kwargs:
252
262
  raise ValueError("Don't mix *args and **kwargs in call to reverse()!")
253
263
 
plain/urls/utils.py CHANGED
@@ -1,10 +1,14 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
1
5
  from plain.utils.functional import lazy
2
6
 
3
7
  from .exceptions import NoReverseMatch
4
8
  from .resolvers import get_ns_resolver, get_resolver
5
9
 
6
10
 
7
- def reverse(url_name: str, *args, **kwargs):
11
+ def reverse(url_name: str, *args: Any, **kwargs: Any) -> str:
8
12
  resolver = get_resolver()
9
13
 
10
14
  *path, view = url_name.split(":")
plain/utils/README.md CHANGED
@@ -1,9 +1,256 @@
1
- # Utils
1
+ # plain.utils
2
2
 
3
- **Various utilities for text manipulation, parsing, dates, and more.**
3
+ **Common utilities for working with dates, text, HTML, and more.**
4
4
 
5
5
  - [Overview](#overview)
6
+ - [Timezone utilities](#timezone-utilities)
7
+ - [Getting the current time](#getting-the-current-time)
8
+ - [Converting between aware and naive datetimes](#converting-between-aware-and-naive-datetimes)
9
+ - [Temporarily changing the timezone](#temporarily-changing-the-timezone)
10
+ - [Time formatting](#time-formatting)
11
+ - [Text utilities](#text-utilities)
12
+ - [Slugify](#slugify)
13
+ - [Truncating text](#truncating-text)
14
+ - [HTML utilities](#html-utilities)
15
+ - [Escaping HTML](#escaping-html)
16
+ - [Formatting HTML safely](#formatting-html-safely)
17
+ - [Stripping tags](#stripping-tags)
18
+ - [Embedding JSON in HTML](#embedding-json-in-html)
19
+ - [Safe strings](#safe-strings)
20
+ - [Random strings](#random-strings)
21
+ - [Date parsing](#date-parsing)
22
+ - [FAQs](#faqs)
23
+ - [Installation](#installation)
6
24
 
7
25
  ## Overview
8
26
 
9
- The utilities aren't going to be documented in detail here. Take a look at the source code for more information.
27
+ The `plain.utils` module provides a collection of utilities that you'll commonly need when building web applications. You can import what you need directly from the submodules:
28
+
29
+ ```python
30
+ from plain.utils.timezone import now, localtime
31
+ from plain.utils.text import slugify
32
+ from plain.utils.html import escape, format_html
33
+
34
+ # Get the current time as a timezone-aware datetime
35
+ current_time = now()
36
+
37
+ # Create a URL-safe slug
38
+ slug = slugify("Hello World!") # "hello-world"
39
+
40
+ # Safely format HTML with escaped values
41
+ html = format_html("<p>Hello, {}!</p>", user_input)
42
+ ```
43
+
44
+ ## Timezone utilities
45
+
46
+ Plain uses timezone-aware datetimes throughout. The timezone utilities help you work with aware datetimes consistently.
47
+
48
+ ### Getting the current time
49
+
50
+ ```python
51
+ from plain.utils.timezone import now
52
+
53
+ current_time = now() # Returns a timezone-aware datetime in UTC
54
+ ```
55
+
56
+ ### Converting between aware and naive datetimes
57
+
58
+ ```python
59
+ from plain.utils.timezone import make_aware, make_naive, is_aware, localtime
60
+ from datetime import datetime
61
+
62
+ # Check if a datetime is aware
63
+ is_aware(some_datetime)
64
+
65
+ # Make a naive datetime aware (uses current timezone by default)
66
+ aware_dt = make_aware(datetime(2024, 1, 15, 10, 30))
67
+
68
+ # Convert to local time
69
+ local_dt = localtime(aware_dt)
70
+
71
+ # Make an aware datetime naive
72
+ naive_dt = make_naive(aware_dt)
73
+ ```
74
+
75
+ ### Temporarily changing the timezone
76
+
77
+ ```python
78
+ from plain.utils.timezone import override, get_current_timezone
79
+
80
+ with override("America/New_York"):
81
+ # Code here uses the New York timezone
82
+ tz = get_current_timezone()
83
+ ```
84
+
85
+ For more timezone functions, see [`timezone.py`](./timezone.py#activate).
86
+
87
+ ## Time formatting
88
+
89
+ Format time differences as human-readable strings.
90
+
91
+ ```python
92
+ from plain.utils.timesince import timesince, timeuntil
93
+ from datetime import datetime, timedelta
94
+ from plain.utils.timezone import now
95
+
96
+ past = now() - timedelta(days=2, hours=3)
97
+ timesince(past) # "2 days, 3 hours"
98
+
99
+ future = now() + timedelta(weeks=1)
100
+ timeuntil(future) # "1 week"
101
+ ```
102
+
103
+ You can use a short format for compact display:
104
+
105
+ ```python
106
+ timesince(past, format="short") # "2d 3h"
107
+ ```
108
+
109
+ ## Text utilities
110
+
111
+ ### Slugify
112
+
113
+ Convert text to a URL-safe slug.
114
+
115
+ ```python
116
+ from plain.utils.text import slugify
117
+
118
+ slugify("Hello World!") # "hello-world"
119
+ slugify("Cafe au lait") # "cafe-au-lait"
120
+ slugify("My Article Title") # "my-article-title"
121
+
122
+ # Preserve unicode characters
123
+ slugify("Ich liebe Berlin", allow_unicode=True) # "ich-liebe-berlin"
124
+ ```
125
+
126
+ ### Truncating text
127
+
128
+ Truncate text by characters or words, with HTML support.
129
+
130
+ ```python
131
+ from plain.utils.text import Truncator
132
+
133
+ text = "This is a long piece of text that needs to be shortened."
134
+ Truncator(text).chars(20) # "This is a long pie..."
135
+ Truncator(text).words(5) # "This is a long piece..."
136
+
137
+ # Truncate HTML while preserving valid structure
138
+ html = "<p>This is <strong>bold</strong> text.</p>"
139
+ Truncator(html).chars(15, html=True) # "<p>This is <strong>bo</strong>...</p>"
140
+ ```
141
+
142
+ ## HTML utilities
143
+
144
+ ### Escaping HTML
145
+
146
+ ```python
147
+ from plain.utils.html import escape
148
+
149
+ escape("<script>alert('xss')</script>")
150
+ # "&lt;script&gt;alert(&#x27;xss&#x27;)&lt;/script&gt;"
151
+ ```
152
+
153
+ ### Formatting HTML safely
154
+
155
+ Build HTML fragments with automatic escaping of values:
156
+
157
+ ```python
158
+ from plain.utils.html import format_html
159
+
160
+ # Values are automatically escaped
161
+ format_html("<a href='{}'>{}</a>", url, link_text)
162
+
163
+ # Safe for untrusted input
164
+ format_html("<p>Welcome, {}!</p>", user_provided_name)
165
+ ```
166
+
167
+ ### Stripping tags
168
+
169
+ Remove HTML tags from text:
170
+
171
+ ```python
172
+ from plain.utils.html import strip_tags
173
+
174
+ strip_tags("<p>Hello <strong>world</strong>!</p>") # "Hello world!"
175
+ ```
176
+
177
+ ### Embedding JSON in HTML
178
+
179
+ Safely embed JSON data in a script tag:
180
+
181
+ ```python
182
+ from plain.utils.html import json_script
183
+
184
+ data = {"user": "john", "count": 42}
185
+ json_script(data, element_id="user-data")
186
+ # '<script id="user-data" type="application/json">{"user": "john", "count": 42}</script>'
187
+ ```
188
+
189
+ ## Safe strings
190
+
191
+ Mark strings as safe to prevent double-escaping.
192
+
193
+ ```python
194
+ from plain.utils.safestring import mark_safe, SafeString
195
+
196
+ # Mark a string as already escaped/safe
197
+ html = mark_safe("<strong>Already safe HTML</strong>")
198
+
199
+ # Check if something is a SafeString
200
+ isinstance(html, SafeString) # True
201
+ ```
202
+
203
+ Use `mark_safe` only when you've manually ensured the content is safe. For building HTML from untrusted input, use `format_html` instead.
204
+
205
+ ## Random strings
206
+
207
+ Generate cryptographically secure random strings.
208
+
209
+ ```python
210
+ from plain.utils.crypto import get_random_string
211
+
212
+ # Default: 12 characters, alphanumeric
213
+ token = get_random_string(12) # e.g., "Kx9mP2nL4qRs"
214
+
215
+ # Custom character set
216
+ pin = get_random_string(6, allowed_chars="0123456789") # e.g., "847293"
217
+ ```
218
+
219
+ ## Date parsing
220
+
221
+ Parse date and time strings into Python objects.
222
+
223
+ ```python
224
+ from plain.utils.dateparse import parse_date, parse_datetime, parse_time, parse_duration
225
+
226
+ parse_date("2024-01-15") # datetime.date(2024, 1, 15)
227
+ parse_datetime("2024-01-15T10:30:00Z") # datetime.datetime(2024, 1, 15, 10, 30, tzinfo=UTC)
228
+ parse_time("10:30:00") # datetime.time(10, 30)
229
+ parse_duration("1 02:30:00") # datetime.timedelta(days=1, hours=2, minutes=30)
230
+ ```
231
+
232
+ These functions return `None` if the input is not well-formatted, and raise `ValueError` if the input is well-formatted but invalid.
233
+
234
+ ## FAQs
235
+
236
+ #### What about the other utilities in this module?
237
+
238
+ The `plain.utils` module contains additional utilities that are primarily used internally by Plain. You can explore the source files directly:
239
+
240
+ - [`datastructures.py`](./datastructures.py) - `MultiValueDict`, `OrderedSet`, `ImmutableList`
241
+ - [`functional.py`](./functional.py) - `SimpleLazyObject`, `lazy`, `classproperty`
242
+ - [`http.py`](./http.py) - `urlencode`, `http_date`, `base36_to_int`
243
+ - [`encoding.py`](./encoding.py) - `force_str`, `force_bytes`
244
+
245
+ #### Should I use `datetime.datetime.now()` or `plain.utils.timezone.now()`?
246
+
247
+ Always use `plain.utils.timezone.now()`. It returns a timezone-aware datetime in UTC, which is what Plain expects throughout the framework.
248
+
249
+ ## Installation
250
+
251
+ The `plain.utils` module is included with Plain.
252
+
253
+ ```python
254
+ from plain.utils.timezone import now
255
+ from plain.utils.text import slugify
256
+ ```
plain/utils/cache.py CHANGED
@@ -15,16 +15,22 @@ An example: i18n middleware would need to distinguish caches by the
15
15
  "Accept-language" header.
16
16
  """
17
17
 
18
+ from __future__ import annotations
19
+
18
20
  import time
19
21
  from collections import defaultdict
22
+ from typing import TYPE_CHECKING, Any
20
23
 
21
24
  from .http import http_date
22
25
  from .regex_helper import _lazy_re_compile
23
26
 
24
- cc_delim_re = _lazy_re_compile(r"\s*,\s*")
27
+ if TYPE_CHECKING:
28
+ from plain.http import ResponseBase
29
+
30
+ _cc_delim_re = _lazy_re_compile(r"\s*,\s*")
25
31
 
26
32
 
27
- def patch_response_headers(response, cache_timeout):
33
+ def patch_response_headers(response: ResponseBase, cache_timeout: int | float) -> None:
28
34
  """
29
35
  Add HTTP caching headers to the given HttpResponse: Expires and
30
36
  Cache-Control.
@@ -38,7 +44,7 @@ def patch_response_headers(response, cache_timeout):
38
44
  patch_cache_control(response, max_age=cache_timeout)
39
45
 
40
46
 
41
- def add_never_cache_headers(response):
47
+ def add_never_cache_headers(response: ResponseBase) -> None:
42
48
  """
43
49
  Add headers to a response to indicate that a page should never be cached.
44
50
  """
@@ -48,7 +54,7 @@ def add_never_cache_headers(response):
48
54
  )
49
55
 
50
56
 
51
- def patch_cache_control(response, **kwargs):
57
+ def patch_cache_control(response: ResponseBase, **kwargs: Any) -> None:
52
58
  """
53
59
  Patch the Cache-Control header by adding all keyword arguments to it.
54
60
  The transformation is as follows:
@@ -61,22 +67,22 @@ def patch_cache_control(response, **kwargs):
61
67
  str() to it.
62
68
  """
63
69
 
64
- def dictitem(s):
70
+ def dictitem(s: str) -> tuple[str, str | bool]:
65
71
  t = s.split("=", 1)
66
72
  if len(t) > 1:
67
73
  return (t[0].lower(), t[1])
68
74
  else:
69
75
  return (t[0].lower(), True)
70
76
 
71
- def dictvalue(*t):
77
+ def dictvalue(*t: str | bool) -> str:
72
78
  if t[1] is True:
73
- return t[0]
79
+ return str(t[0])
74
80
  else:
75
81
  return f"{t[0]}={t[1]}"
76
82
 
77
83
  cc = defaultdict(set)
78
84
  if response.headers.get("Cache-Control"):
79
- for field in cc_delim_re.split(response.headers["Cache-Control"]):
85
+ for field in _cc_delim_re.split(response.headers["Cache-Control"]):
80
86
  directive, value = dictitem(field)
81
87
  if directive == "no-cache":
82
88
  # no-cache supports multiple field names.
@@ -117,7 +123,7 @@ def patch_cache_control(response, **kwargs):
117
123
  response.headers["Cache-Control"] = cc
118
124
 
119
125
 
120
- def patch_vary_headers(response, newheaders):
126
+ def patch_vary_headers(response: ResponseBase, newheaders: list[str]) -> None:
121
127
  """
122
128
  Add (or update) the "Vary" header in the given Response object.
123
129
  newheaders is a list of header names that should be in "Vary". If headers
@@ -128,7 +134,7 @@ def patch_vary_headers(response, newheaders):
128
134
  # implementations may rely on the order of the Vary contents in, say,
129
135
  # computing an MD5 hash.
130
136
  if "Vary" in response.headers:
131
- vary_headers = cc_delim_re.split(response.headers["Vary"])
137
+ vary_headers = _cc_delim_re.split(response.headers["Vary"])
132
138
  else:
133
139
  vary_headers = []
134
140
  # Use .lower() here so we treat headers as case-insensitive.
@@ -145,7 +151,7 @@ def patch_vary_headers(response, newheaders):
145
151
  response.headers["Vary"] = ", ".join(vary_headers)
146
152
 
147
153
 
148
- def _to_tuple(s):
154
+ def _to_tuple(s: str) -> tuple[str, str | bool]:
149
155
  t = s.split("=", 1)
150
156
  if len(t) == 2:
151
157
  return t[0].lower(), t[1]
plain/utils/crypto.py CHANGED
@@ -2,9 +2,13 @@
2
2
  Plain's standard crypto functions and utilities.
3
3
  """
4
4
 
5
+ from __future__ import annotations
6
+
5
7
  import hashlib
6
8
  import hmac
7
9
  import secrets
10
+ from collections.abc import Callable
11
+ from typing import Any
8
12
 
9
13
  from plain.runtime import settings
10
14
  from plain.utils.encoding import force_bytes
@@ -16,7 +20,13 @@ class InvalidAlgorithm(ValueError):
16
20
  pass
17
21
 
18
22
 
19
- def salted_hmac(key_salt, value, secret=None, *, algorithm="sha1"):
23
+ def salted_hmac(
24
+ key_salt: str | bytes,
25
+ value: str | bytes,
26
+ secret: str | bytes | None = None,
27
+ *,
28
+ algorithm: str = "sha1",
29
+ ) -> hmac.HMAC:
20
30
  """
21
31
  Return the HMAC of 'value', using a key generated from key_salt and a
22
32
  secret (which defaults to settings.SECRET_KEY). Default algorithm is SHA1,
@@ -48,7 +58,7 @@ def salted_hmac(key_salt, value, secret=None, *, algorithm="sha1"):
48
58
  RANDOM_STRING_CHARS = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
49
59
 
50
60
 
51
- def get_random_string(length, allowed_chars=RANDOM_STRING_CHARS):
61
+ def get_random_string(length: int, allowed_chars: str = RANDOM_STRING_CHARS) -> str:
52
62
  """
53
63
  Return a securely generated random string.
54
64
 
@@ -62,11 +72,17 @@ def get_random_string(length, allowed_chars=RANDOM_STRING_CHARS):
62
72
  return "".join(secrets.choice(allowed_chars) for i in range(length))
63
73
 
64
74
 
65
- def pbkdf2(password, salt, iterations, dklen=0, digest=None):
75
+ def pbkdf2(
76
+ password: str | bytes,
77
+ salt: str | bytes,
78
+ iterations: int,
79
+ dklen: int = 0,
80
+ digest: Callable[[], Any] | None = None,
81
+ ) -> bytes:
66
82
  """Return the hash of password using pbkdf2."""
67
83
  if digest is None:
68
84
  digest = hashlib.sha256
69
- dklen = dklen or None
85
+ dklen_value: int | None = dklen if dklen else None
70
86
  password = force_bytes(password)
71
87
  salt = force_bytes(salt)
72
- return hashlib.pbkdf2_hmac(digest().name, password, salt, iterations, dklen)
88
+ return hashlib.pbkdf2_hmac(digest().name, password, salt, iterations, dklen_value)