plain 0.66.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 (197) hide show
  1. plain/CHANGELOG.md +684 -0
  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 -53
  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 +112 -28
  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 +175 -102
  25. plain/cli/print.py +4 -4
  26. plain/cli/registry.py +95 -26
  27. plain/cli/request.py +206 -0
  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 -13
  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 +40 -15
  82. plain/paginator.py +31 -21
  83. plain/preflight/README.md +208 -23
  84. plain/preflight/__init__.py +5 -24
  85. plain/preflight/checks.py +12 -0
  86. plain/preflight/files.py +19 -13
  87. plain/preflight/registry.py +80 -58
  88. plain/preflight/results.py +37 -0
  89. plain/preflight/security.py +65 -71
  90. plain/preflight/settings.py +54 -0
  91. plain/preflight/urls.py +10 -48
  92. plain/runtime/README.md +115 -47
  93. plain/runtime/__init__.py +10 -6
  94. plain/runtime/global_settings.py +43 -33
  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 +14 -27
  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 +56 -40
  145. plain/urls/resolvers.py +38 -28
  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.66.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.66.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/cli/agent/request.py +0 -181
  191. plain/csrf/views.py +0 -31
  192. plain/logs/utils.py +0 -46
  193. plain/preflight/messages.py +0 -81
  194. plain/templates/AGENTS.md +0 -3
  195. plain-0.66.0.dist-info/RECORD +0 -168
  196. plain-0.66.0.dist-info/entry_points.txt +0 -4
  197. {plain-0.66.0.dist-info → plain-0.101.2.dist-info}/licenses/LICENSE +0 -0
plain/README.md CHANGED
@@ -31,7 +31,7 @@ The `plain` package includes everything you need to start handling web requests
31
31
  - [plain.cache](/plain-cache/plain/cache/README.md) - A database-driven general purpose cache.
32
32
  - [plain.email](/plain-email/plain/email/README.md) - Send emails with SMTP or custom backends.
33
33
  - [plain.sessions](/plain-sessions/plain/sessions/README.md) - User sessions and cookies.
34
- - [plain.worker](/plain-worker/plain/worker/README.md) - Background jobs stored in the database.
34
+ - [plain.jobs](/plain-jobs/plain/jobs/README.md) - Background jobs stored in the database.
35
35
  - [plain.api](/plain-api/plain/api/README.md) - Build APIs with Plain views.
36
36
 
37
37
  ## Auth Packages
plain/assets/compile.py CHANGED
@@ -1,13 +1,17 @@
1
+ from __future__ import annotations
2
+
1
3
  import gzip
2
4
  import os
3
5
  import shutil
6
+ from collections.abc import Iterator
7
+ from pathlib import Path
4
8
 
5
9
  from plain.runtime import PLAIN_TEMP_PATH
6
10
 
7
- from .finders import iter_assets
8
- from .fingerprints import AssetsFingerprintsManifest, get_file_fingerprint
11
+ from .finders import Asset, _iter_assets
12
+ from .fingerprints import AssetsFingerprintsManifest, _get_file_fingerprint
9
13
 
10
- SKIP_COMPRESS_EXTENSIONS = (
14
+ _SKIP_COMPRESS_EXTENSIONS = (
11
15
  # Images
12
16
  ".jpg",
13
17
  ".jpeg",
@@ -40,7 +44,7 @@ SKIP_COMPRESS_EXTENSIONS = (
40
44
  )
41
45
 
42
46
 
43
- def get_compiled_path():
47
+ def get_compiled_path() -> Path:
44
48
  """
45
49
  Get the path at runtime to the compiled assets directory.
46
50
 
@@ -49,14 +53,16 @@ def get_compiled_path():
49
53
  return PLAIN_TEMP_PATH / "assets" / "compiled"
50
54
 
51
55
 
52
- def compile_assets(*, target_dir, keep_original, fingerprint, compress):
56
+ def compile_assets(
57
+ *, target_dir: str, keep_original: bool, fingerprint: bool, compress: bool
58
+ ) -> Iterator[tuple[str, str, list[str]]]:
53
59
  """
54
60
  Compile all assets to the target directory and save a JSON manifest
55
61
  mapping the original filenames to the compiled filenames.
56
62
  """
57
63
  manifest = AssetsFingerprintsManifest()
58
64
 
59
- for asset in iter_assets():
65
+ for asset in _iter_assets():
60
66
  url_path = asset.url_path
61
67
  resolved_path, compiled_paths = compile_asset(
62
68
  asset=asset,
@@ -73,17 +79,24 @@ def compile_assets(*, target_dir, keep_original, fingerprint, compress):
73
79
  manifest.save()
74
80
 
75
81
 
76
- def compile_asset(*, asset, target_dir, keep_original, fingerprint, compress):
82
+ def compile_asset(
83
+ *,
84
+ asset: Asset,
85
+ target_dir: str,
86
+ keep_original: bool,
87
+ fingerprint: bool,
88
+ compress: bool,
89
+ ) -> tuple[str, list[str]]:
77
90
  """
78
91
  Compile an asset to multiple output paths.
79
92
  """
80
- compiled_paths = []
93
+ compiled_paths: list[str] = []
81
94
 
82
95
  # The expected destination for the original asset
83
96
  target_path = os.path.join(target_dir, asset.url_path)
84
97
 
85
98
  # Keep track of where the final, resolved asset ends up
86
- resolved_url_path = asset.url_path
99
+ resolved_url_path: str = asset.url_path
87
100
 
88
101
  # Make sure all the expected directories exist
89
102
  os.makedirs(os.path.dirname(target_path), exist_ok=True)
@@ -99,16 +112,16 @@ def compile_asset(*, asset, target_dir, keep_original, fingerprint, compress):
99
112
  # Fingerprint it with an md5 hash
100
113
  # (maybe need a setting with fnmatch patterns for files to NOT fingerprint?
101
114
  # that would allow pre-fingerprinted files to be used as-is, and keep source maps etc in tact)
102
- fingerprint_hash = get_file_fingerprint(asset.absolute_path)
115
+ fingerprint_hash = _get_file_fingerprint(asset.absolute_path)
103
116
 
104
117
  fingerprinted_basename = f"{base}.{fingerprint_hash}{extension}"
105
118
  fingerprinted_path = os.path.join(target_dir, fingerprinted_basename)
106
119
  shutil.copy(asset.absolute_path, fingerprinted_path)
107
120
  compiled_paths.append(fingerprinted_path)
108
121
 
109
- resolved_url_path = os.path.relpath(fingerprinted_path, target_dir)
122
+ resolved_url_path = str(os.path.relpath(fingerprinted_path, target_dir))
110
123
 
111
- if compress and extension.lower() not in SKIP_COMPRESS_EXTENSIONS:
124
+ if compress and extension.lower() not in _SKIP_COMPRESS_EXTENSIONS:
112
125
  for path in compiled_paths.copy():
113
126
  gzip_path = f"{path}.gz"
114
127
  with gzip.GzipFile(gzip_path, "wb") as f:
plain/assets/finders.py CHANGED
@@ -1,42 +1,48 @@
1
+ from __future__ import annotations
2
+
1
3
  import os
4
+ from collections.abc import Iterator
2
5
 
6
+ from plain.internal import internalcode
3
7
  from plain.packages import packages_registry
4
8
  from plain.runtime import APP_PATH
5
9
 
6
- APP_ASSETS_DIR = APP_PATH / "assets"
10
+ _APP_ASSETS_DIR = APP_PATH / "assets"
11
+
12
+ _SKIP_ASSETS = (".DS_Store", ".gitignore")
13
+
7
14
 
8
- SKIP_ASSETS = (".DS_Store", ".gitignore")
15
+ @internalcode
16
+ class Asset:
17
+ def __init__(self, *, url_path: str, absolute_path: str):
18
+ self.url_path = url_path
19
+ self.absolute_path = absolute_path
9
20
 
21
+ def __str__(self) -> str:
22
+ return self.url_path
10
23
 
11
- def iter_assets():
24
+
25
+ def _iter_assets() -> Iterator[Asset]:
12
26
  """
13
27
  Iterate all valid asset files found in the installed
14
28
  packages and the app itself.
15
29
  """
16
30
 
17
- class Asset:
18
- def __init__(self, *, url_path, absolute_path):
19
- self.url_path = url_path
20
- self.absolute_path = absolute_path
21
-
22
- def __str__(self):
23
- return self.url_path
24
-
25
- def _iter_assets_dir(path):
31
+ def __iter_assets_dir(path: str) -> Iterator[tuple[str, str]]:
26
32
  for root, _, files in os.walk(path):
27
33
  for f in files:
28
- if f in SKIP_ASSETS:
34
+ if f in _SKIP_ASSETS:
29
35
  continue
30
36
  abs_path = os.path.join(root, f)
31
37
  url_path = os.path.relpath(abs_path, path)
32
38
  yield url_path, abs_path
33
39
 
34
- for asset_dir in iter_asset_dirs():
35
- for url_path, abs_path in _iter_assets_dir(asset_dir):
40
+ for asset_dir in _iter_asset_dirs():
41
+ for url_path, abs_path in __iter_assets_dir(asset_dir):
36
42
  yield Asset(url_path=url_path, absolute_path=abs_path)
37
43
 
38
44
 
39
- def iter_asset_dirs():
45
+ def _iter_asset_dirs() -> Iterator[str]:
40
46
  """
41
47
  Iterate all directories containing assets, from installed
42
48
  packages and from app/assets.
@@ -48,4 +54,5 @@ def iter_asset_dirs():
48
54
  yield asset_dir
49
55
 
50
56
  # The app/assets take priority over everything
51
- yield APP_ASSETS_DIR
57
+ if _APP_ASSETS_DIR.exists():
58
+ yield _APP_ASSETS_DIR
@@ -2,11 +2,13 @@ import hashlib
2
2
  import json
3
3
  from functools import cache
4
4
 
5
+ from plain.internal import internalcode
5
6
  from plain.runtime import PLAIN_TEMP_PATH
6
7
 
7
- FINGERPRINT_LENGTH = 7
8
+ _FINGERPRINT_LENGTH = 7
8
9
 
9
10
 
11
+ @internalcode
10
12
  class AssetsFingerprintsManifest(dict):
11
13
  """
12
14
  A manifest of original filenames to fingerprinted filenames.
@@ -15,18 +17,18 @@ class AssetsFingerprintsManifest(dict):
15
17
  def __init__(self):
16
18
  self.path = PLAIN_TEMP_PATH / "assets" / "fingerprints.json"
17
19
 
18
- def load(self):
20
+ def load(self) -> None:
19
21
  if self.path.exists():
20
22
  with open(self.path) as f:
21
23
  self.update(json.load(f))
22
24
 
23
- def save(self):
25
+ def save(self) -> None:
24
26
  with open(self.path, "w") as f:
25
27
  json.dump(self, f, indent=2)
26
28
 
27
29
 
28
30
  @cache
29
- def _get_manifest():
31
+ def _get_manifest() -> AssetsFingerprintsManifest:
30
32
  """
31
33
  A cached function for loading the asset fingerprints manifest,
32
34
  so we don't have to keep loading it from disk over and over.
@@ -36,23 +38,24 @@ def _get_manifest():
36
38
  return manifest
37
39
 
38
40
 
39
- def get_fingerprinted_url_path(url_path):
41
+ def get_fingerprinted_url_path(url_path: str) -> str | None:
40
42
  """
41
43
  Get the final fingerprinted path for an asset URL path.
42
44
  """
43
45
  manifest = _get_manifest()
44
46
  if url_path in manifest:
45
47
  return manifest[url_path]
48
+ return None
46
49
 
47
50
 
48
- def get_file_fingerprint(file_path):
51
+ def _get_file_fingerprint(file_path: str) -> str:
49
52
  """
50
53
  Get the fingerprint hash for a file.
51
54
  """
52
55
  with open(file_path, "rb") as f:
53
56
  content = f.read()
54
57
  fingerprint_hash = hashlib.md5(content, usedforsecurity=False).hexdigest()[
55
- :FINGERPRINT_LENGTH
58
+ :_FINGERPRINT_LENGTH
56
59
  ]
57
60
 
58
61
  return fingerprint_hash
plain/assets/urls.py CHANGED
@@ -18,7 +18,7 @@ class AssetsRouter(Router):
18
18
  ]
19
19
 
20
20
 
21
- def get_asset_url(url_path):
21
+ def get_asset_url(url_path: str) -> str:
22
22
  """
23
23
  Get the full URL to a given asset path.
24
24
  """
plain/assets/views.py CHANGED
@@ -1,3 +1,5 @@
1
+ from __future__ import annotations
2
+
1
3
  import functools
2
4
  import mimetypes
3
5
  import os
@@ -6,19 +8,20 @@ from io import BytesIO
6
8
 
7
9
  from plain.http import (
8
10
  FileResponse,
9
- Http404,
11
+ NotFoundError404,
12
+ NotModifiedResponse,
13
+ RedirectResponse,
10
14
  Response,
11
- ResponseNotModified,
12
- ResponseRedirect,
13
15
  StreamingResponse,
14
16
  )
17
+ from plain.http.response import ResponseHeaders
15
18
  from plain.runtime import settings
16
19
  from plain.urls import reverse
17
20
  from plain.views import View
18
21
 
19
22
  from .compile import get_compiled_path
20
- from .finders import iter_assets
21
- from .fingerprints import FINGERPRINT_LENGTH, get_fingerprinted_url_path
23
+ from .finders import _iter_assets
24
+ from .fingerprints import _FINGERPRINT_LENGTH, get_fingerprinted_url_path
22
25
 
23
26
 
24
27
  class AssetView(View):
@@ -28,16 +31,19 @@ class AssetView(View):
28
31
  This class could be subclassed to further tweak the responses or behavior.
29
32
  """
30
33
 
31
- def __init__(self, asset_path=None):
34
+ def __init__(self, asset_path: str | None = None):
32
35
  # Allow a path to be passed in AssetView.as_view(path="...")
33
36
  self.asset_path = asset_path
34
37
 
35
- def get_url_path(self):
38
+ def get_url_path(self) -> str | None:
36
39
  return self.asset_path or self.url_kwargs["path"]
37
40
 
38
- def get(self):
41
+ def get(self) -> Response | FileResponse | StreamingResponse:
39
42
  url_path = self.get_url_path()
40
43
 
44
+ if not url_path:
45
+ raise NotFoundError404("Asset path not found")
46
+
41
47
  # Make a trailing slash work, but we don't expect it
42
48
  url_path = url_path.rstrip("/")
43
49
 
@@ -50,7 +56,11 @@ class AssetView(View):
50
56
  if redirect_response := self.get_redirect_response(url_path):
51
57
  return redirect_response
52
58
 
59
+ # check_asset_path validates and raises if path is invalid
60
+ # After this point, absolute_path is guaranteed to be a valid str
53
61
  self.check_asset_path(absolute_path)
62
+ # Type guard: absolute_path is now str (check_asset_path raises if None/invalid)
63
+ assert absolute_path is not None
54
64
 
55
65
  if encoded_path := self.get_encoded_path(absolute_path):
56
66
  absolute_path = encoded_path
@@ -71,35 +81,36 @@ class AssetView(View):
71
81
  response.headers = self.update_headers(response.headers, absolute_path)
72
82
  return response
73
83
 
74
- def get_asset_path(self, path):
84
+ def get_asset_path(self, path: str) -> str:
75
85
  """Get the path to the compiled asset"""
76
86
  compiled_path = os.path.abspath(get_compiled_path())
77
87
  asset_path = os.path.join(compiled_path, path)
78
88
 
79
89
  # Make sure we don't try to escape the compiled assests path
80
90
  if not os.path.commonpath([compiled_path, asset_path]) == compiled_path:
81
- raise Http404("Asset not found")
91
+ raise NotFoundError404("Asset not found")
82
92
 
83
93
  return asset_path
84
94
 
85
- def get_debug_asset_path(self, path):
95
+ def get_debug_asset_path(self, path: str) -> str | None:
86
96
  """Make a "live" check to find the uncompiled asset in the filesystem"""
87
- for asset in iter_assets():
97
+ for asset in _iter_assets():
88
98
  if asset.url_path == path:
89
99
  return asset.absolute_path
100
+ return None
90
101
 
91
- def check_asset_path(self, path):
102
+ def check_asset_path(self, path: str | None) -> None:
92
103
  if not path:
93
- raise Http404("Asset not found")
104
+ raise NotFoundError404("Asset not found")
94
105
 
95
106
  if not os.path.exists(path):
96
- raise Http404("Asset not found")
107
+ raise NotFoundError404("Asset not found")
97
108
 
98
109
  if os.path.isdir(path):
99
- raise Http404("Asset is a directory")
110
+ raise NotFoundError404("Asset is a directory")
100
111
 
101
112
  @functools.cache
102
- def get_last_modified(self, path):
113
+ def get_last_modified(self, path: str) -> str | None:
103
114
  try:
104
115
  mtime = os.path.getmtime(path)
105
116
  except OSError:
@@ -107,23 +118,24 @@ class AssetView(View):
107
118
 
108
119
  if mtime:
109
120
  return formatdate(mtime, usegmt=True)
121
+ return None
110
122
 
111
123
  @functools.cache
112
- def get_etag(self, path):
124
+ def get_etag(self, path: str) -> str:
113
125
  try:
114
126
  mtime = os.path.getmtime(path)
115
127
  except OSError:
116
- mtime = None
128
+ mtime = 0.0
117
129
 
118
130
  timestamp = int(mtime)
119
131
  size = self.get_size(path)
120
132
  return f'"{timestamp:x}-{size:x}"'
121
133
 
122
134
  @functools.cache
123
- def get_size(self, path):
135
+ def get_size(self, path: str) -> int:
124
136
  return os.path.getsize(path)
125
137
 
126
- def update_headers(self, headers, path):
138
+ def update_headers(self, headers: ResponseHeaders, path: str) -> ResponseHeaders:
127
139
  headers.setdefault("Access-Control-Allow-Origin", "*")
128
140
 
129
141
  # Always vary on Accept-Encoding
@@ -165,7 +177,7 @@ class AssetView(View):
165
177
 
166
178
  return headers
167
179
 
168
- def is_immutable(self, path):
180
+ def is_immutable(self, path: str) -> bool:
169
181
  """
170
182
  Determine whether an asset looks like it is immutable.
171
183
 
@@ -177,19 +189,19 @@ class AssetView(View):
177
189
  extension = None
178
190
  while extension != "":
179
191
  base, extension = os.path.splitext(base)
180
- if len(extension) == FINGERPRINT_LENGTH + 1 and extension[1:].isalnum():
192
+ if len(extension) == _FINGERPRINT_LENGTH + 1 and extension[1:].isalnum():
181
193
  return True
182
194
 
183
195
  return False
184
196
 
185
- def get_encoded_path(self, path):
197
+ def get_encoded_path(self, path: str) -> str | None:
186
198
  """
187
199
  If the client supports compression, return the path to the compressed file.
188
200
  Otherwise, return the original path.
189
201
  """
190
202
  accept_encoding = self.request.headers.get("Accept-Encoding")
191
203
  if not accept_encoding:
192
- return
204
+ return None
193
205
 
194
206
  if "br" in accept_encoding:
195
207
  br_path = path + ".br"
@@ -200,33 +212,34 @@ class AssetView(View):
200
212
  gzip_path = path + ".gz"
201
213
  if os.path.exists(gzip_path):
202
214
  return gzip_path
215
+ return None
203
216
 
204
- def get_redirect_response(self, path):
217
+ def get_redirect_response(self, path: str) -> RedirectResponse | None:
205
218
  """If the asset is not found, try to redirect to the fingerprinted path"""
206
219
  fingerprinted_url_path = get_fingerprinted_url_path(path)
207
220
 
208
221
  if not fingerprinted_url_path or fingerprinted_url_path == path:
209
222
  # Don't need to redirect if there is no fingerprinted path,
210
223
  # or we're already looking at it.
211
- return
224
+ return None
212
225
 
213
226
  from .urls import AssetsRouter
214
227
 
215
228
  namespace = AssetsRouter.namespace
216
229
 
217
- return ResponseRedirect(
230
+ return RedirectResponse(
218
231
  redirect_to=reverse(f"{namespace}:asset", fingerprinted_url_path),
219
232
  headers={
220
233
  "Cache-Control": "max-age=60", # Can cache this for a short time, but the fingerprinted path can change
221
234
  },
222
235
  )
223
236
 
224
- def get_conditional_response(self, path):
237
+ def get_conditional_response(self, path: str) -> NotModifiedResponse | None:
225
238
  """
226
239
  Support conditional requests (HTTP 304 response) based on ETag and Last-Modified headers.
227
240
  """
228
241
  if self.request.headers.get("If-None-Match") == self.get_etag(path):
229
- response = ResponseNotModified()
242
+ response = NotModifiedResponse()
230
243
  response.headers = self.update_headers(response.headers, path)
231
244
  return response
232
245
 
@@ -238,11 +251,12 @@ class AssetView(View):
238
251
  and last_modified
239
252
  and if_modified_since >= last_modified
240
253
  ):
241
- response = ResponseNotModified()
254
+ response = NotModifiedResponse()
242
255
  response.headers = self.update_headers(response.headers, path)
243
256
  return response
257
+ return None
244
258
 
245
- def get_range_response(self, path):
259
+ def get_range_response(self, path: str) -> Response | StreamingResponse | None:
246
260
  """
247
261
  Support range requests (HTTP 206 response).
248
262
  """
@@ -266,7 +280,7 @@ class AssetView(View):
266
280
  status_code=416, headers=[("Content-Range", f"bytes */{file_size}")]
267
281
  )
268
282
 
269
- end = min(end, file_size - 1)
283
+ end = int(min(end, file_size - 1))
270
284
 
271
285
  with open(path, "rb") as f:
272
286
  f.seek(start)
plain/chores/README.md CHANGED
@@ -8,7 +8,7 @@
8
8
 
9
9
  ## Overview
10
10
 
11
- Chores are registered functions that can be run at any time to keep an app in a desirable state.
11
+ Chores are registered classes that can be run at any time to keep an app in a desirable state.
12
12
 
13
13
  ![](https://assets.plainframework.com/docs/plain-chores-run.png)
14
14
 
@@ -16,19 +16,19 @@ A good example is the clearing of expired sessions in [`plain.sessions`](/plain-
16
16
 
17
17
  ```python
18
18
  # plain/sessions/chores.py
19
- from plain.chores import register_chore
19
+ from plain.chores import Chore, register_chore
20
20
  from plain.utils import timezone
21
21
 
22
22
  from .models import Session
23
23
 
24
24
 
25
- @register_chore("sessions")
26
- def clear_expired():
27
- """
28
- Delete sessions that have expired.
29
- """
30
- result = Session.query.filter(expires_at__lt=timezone.now()).delete()
31
- return f"{result[0]} expired sessions deleted"
25
+ @register_chore
26
+ class ClearExpired(Chore):
27
+ """Delete sessions that have expired."""
28
+
29
+ def run(self):
30
+ result = Session.query.filter(expires_at__lt=timezone.now()).delete()
31
+ return f"{result[0]} expired sessions deleted"
32
32
  ```
33
33
 
34
34
  ## Running chores
@@ -38,33 +38,35 @@ The `plain chores run` command will execute all registered chores. When and how
38
38
  There are several ways you can run chores depending on your needs:
39
39
 
40
40
  - on deploy
41
- - as a [`plain.worker` scheduled job](/plain-worker/plain/worker/README.md#scheduled-jobs)
41
+ - as a [`plain.jobs` scheduled job](/plain-jobs/plain/jobs/README.md#scheduled-jobs)
42
42
  - as a cron job (using any cron-like system where your app is hosted)
43
43
  - manually as needed
44
44
 
45
45
  ## Writing chores
46
46
 
47
- A chore is a function decorated with `@register_chore(chore_group_name)`. It can write a description as a docstring, and it can return a value that will be printed when the chore is run.
47
+ A chore is a class that inherits from [`Chore`](./core.py#Chore) and implements the `run()` method. Register the chore using the [`@register_chore`](./registry.py#register_chore) decorator. The chore name is the class's qualified name (`__qualname__`), and the description comes from the class docstring.
48
48
 
49
49
  ```python
50
50
  # app/chores.py
51
- from plain.chores import register_chore
51
+ from plain.chores import Chore, register_chore
52
+
52
53
 
54
+ @register_chore
55
+ class ChoreName(Chore):
56
+ """A chore description can go here."""
53
57
 
54
- @register_chore("app")
55
- def chore_name():
56
- """
57
- A chore description can go here
58
- """
59
- # Do a thing!
60
- return "We did it!"
58
+ def run(self):
59
+ # Do a thing!
60
+ return "We did it!"
61
61
  ```
62
62
 
63
+ ### Best practices
64
+
63
65
  A good chore is:
64
66
 
65
- - Fast
66
- - Idempotent
67
- - Recurring
68
- - Stateless
67
+ - **Fast** - Should complete quickly, not block for long periods
68
+ - **Idempotent** - Safe to run multiple times without side effects
69
+ - **Recurring** - Designed to run regularly, not just once
70
+ - **Stateless** - Doesn't rely on external state between runs
69
71
 
70
72
  If chores are written in `app/chores.py` or `{pkg}/chores.py`, then they will be imported automatically and registered.
plain/chores/__init__.py CHANGED
@@ -1,3 +1,4 @@
1
+ from .core import Chore
1
2
  from .registry import register_chore
2
3
 
3
- __all__ = ["register_chore"]
4
+ __all__ = ["Chore", "register_chore"]
plain/chores/core.py ADDED
@@ -0,0 +1,27 @@
1
+ from __future__ import annotations
2
+
3
+ from abc import ABC, abstractmethod
4
+ from typing import Any
5
+
6
+
7
+ class Chore(ABC):
8
+ """
9
+ Abstract base class for chores.
10
+
11
+ Subclasses must implement:
12
+ - run() method
13
+
14
+ Example:
15
+ @register_chore
16
+ class ClearExpired(Chore):
17
+ '''Delete sessions that have expired.'''
18
+
19
+ def run(self):
20
+ # ... implementation
21
+ return "10 sessions deleted"
22
+ """
23
+
24
+ @abstractmethod
25
+ def run(self) -> Any:
26
+ """Run the chore. Must be implemented by subclasses."""
27
+ pass