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.
- plain/CHANGELOG.md +684 -0
- 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 -53
- 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 +112 -28
- 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 +175 -102
- plain/cli/print.py +4 -4
- plain/cli/registry.py +95 -26
- plain/cli/request.py +206 -0
- 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 -13
- 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 +40 -15
- plain/paginator.py +31 -21
- plain/preflight/README.md +208 -23
- plain/preflight/__init__.py +5 -24
- plain/preflight/checks.py +12 -0
- plain/preflight/files.py +19 -13
- plain/preflight/registry.py +80 -58
- plain/preflight/results.py +37 -0
- plain/preflight/security.py +65 -71
- plain/preflight/settings.py +54 -0
- plain/preflight/urls.py +10 -48
- plain/runtime/README.md +115 -47
- plain/runtime/__init__.py +10 -6
- plain/runtime/global_settings.py +43 -33
- 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 +14 -27
- 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 +56 -40
- plain/urls/resolvers.py +38 -28
- 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.66.0.dist-info → plain-0.101.2.dist-info}/METADATA +4 -2
- plain-0.101.2.dist-info/RECORD +201 -0
- {plain-0.66.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/cli/agent/request.py +0 -181
- plain/csrf/views.py +0 -31
- plain/logs/utils.py +0 -46
- plain/preflight/messages.py +0 -81
- plain/templates/AGENTS.md +0 -3
- plain-0.66.0.dist-info/RECORD +0 -168
- plain-0.66.0.dist-info/entry_points.txt +0 -4
- {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.
|
|
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
|
|
8
|
-
from .fingerprints import AssetsFingerprintsManifest,
|
|
11
|
+
from .finders import Asset, _iter_assets
|
|
12
|
+
from .fingerprints import AssetsFingerprintsManifest, _get_file_fingerprint
|
|
9
13
|
|
|
10
|
-
|
|
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(
|
|
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
|
|
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(
|
|
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 =
|
|
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
|
|
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
|
-
|
|
10
|
+
_APP_ASSETS_DIR = APP_PATH / "assets"
|
|
11
|
+
|
|
12
|
+
_SKIP_ASSETS = (".DS_Store", ".gitignore")
|
|
13
|
+
|
|
7
14
|
|
|
8
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
35
|
-
for url_path, abs_path in
|
|
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
|
|
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
|
-
|
|
57
|
+
if _APP_ASSETS_DIR.exists():
|
|
58
|
+
yield _APP_ASSETS_DIR
|
plain/assets/fingerprints.py
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
:
|
|
58
|
+
:_FINGERPRINT_LENGTH
|
|
56
59
|
]
|
|
57
60
|
|
|
58
61
|
return fingerprint_hash
|
plain/assets/urls.py
CHANGED
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
|
-
|
|
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
|
|
21
|
-
from .fingerprints import
|
|
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
|
|
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
|
|
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
|
|
104
|
+
raise NotFoundError404("Asset not found")
|
|
94
105
|
|
|
95
106
|
if not os.path.exists(path):
|
|
96
|
-
raise
|
|
107
|
+
raise NotFoundError404("Asset not found")
|
|
97
108
|
|
|
98
109
|
if os.path.isdir(path):
|
|
99
|
-
raise
|
|
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 =
|
|
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) ==
|
|
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
|
|
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 =
|
|
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 =
|
|
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
|
|
11
|
+
Chores are registered classes that can be run at any time to keep an app in a desirable state.
|
|
12
12
|
|
|
13
13
|

|
|
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
|
|
26
|
-
|
|
27
|
-
"""
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
55
|
-
|
|
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
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
|