plain 0.1.1__tar.gz → 0.2.0__tar.gz
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-0.1.1 → plain-0.2.0}/LICENSE +0 -24
- {plain-0.1.1 → plain-0.2.0}/PKG-INFO +1 -1
- plain-0.2.0/plain/assets/README.md +37 -0
- plain-0.2.0/plain/assets/compile.py +111 -0
- plain-0.2.0/plain/assets/finders.py +41 -0
- plain-0.2.0/plain/assets/fingerprints.py +38 -0
- plain-0.2.0/plain/assets/urls.py +31 -0
- plain-0.2.0/plain/assets/views.py +263 -0
- {plain-0.1.1 → plain-0.2.0}/plain/cli/cli.py +68 -5
- plain-0.2.0/plain/csrf/README.md +15 -0
- {plain-0.1.1 → plain-0.2.0}/plain/packages/config.py +5 -5
- {plain-0.1.1 → plain-0.2.0}/plain/packages/registry.py +1 -7
- {plain-0.1.1 → plain-0.2.0}/plain/preflight/urls.py +0 -10
- {plain-0.1.1 → plain-0.2.0}/plain/runtime/README.md +0 -1
- {plain-0.1.1 → plain-0.2.0}/plain/runtime/__init__.py +1 -1
- {plain-0.1.1 → plain-0.2.0}/plain/runtime/global_settings.py +7 -14
- {plain-0.1.1 → plain-0.2.0}/plain/runtime/user_settings.py +0 -49
- {plain-0.1.1 → plain-0.2.0}/plain/templates/jinja/globals.py +1 -1
- plain-0.2.0/plain/test/__init__.py +8 -0
- {plain-0.1.1 → plain-0.2.0}/plain/test/client.py +36 -16
- {plain-0.1.1 → plain-0.2.0}/plain/views/base.py +5 -3
- {plain-0.1.1 → plain-0.2.0}/plain/views/errors.py +7 -0
- {plain-0.1.1 → plain-0.2.0}/pyproject.toml +1 -1
- plain-0.1.1/plain/assets/README.md +0 -56
- plain-0.1.1/plain/assets/__init__.py +0 -6
- plain-0.1.1/plain/assets/finders.py +0 -233
- plain-0.1.1/plain/assets/preflight.py +0 -14
- plain-0.1.1/plain/assets/storage.py +0 -916
- plain-0.1.1/plain/assets/utils.py +0 -52
- plain-0.1.1/plain/assets/whitenoise/__init__.py +0 -5
- plain-0.1.1/plain/assets/whitenoise/base.py +0 -259
- plain-0.1.1/plain/assets/whitenoise/compress.py +0 -189
- plain-0.1.1/plain/assets/whitenoise/media_types.py +0 -137
- plain-0.1.1/plain/assets/whitenoise/middleware.py +0 -197
- plain-0.1.1/plain/assets/whitenoise/responders.py +0 -286
- plain-0.1.1/plain/assets/whitenoise/storage.py +0 -178
- plain-0.1.1/plain/assets/whitenoise/string_utils.py +0 -13
- plain-0.1.1/plain/csrf/README.md +0 -3
- plain-0.1.1/plain/internal/legacy/management/commands/collectstatic.py +0 -297
- plain-0.1.1/plain/test/__init__.py +0 -16
- plain-0.1.1/plain/test/utils.py +0 -255
- {plain-0.1.1 → plain-0.2.0}/README.md +0 -0
- {plain-0.1.1 → plain-0.2.0}/plain/README.md +0 -0
- {plain-0.1.1 → plain-0.2.0}/plain/__main__.py +0 -0
- {plain-0.1.1/plain/internal → plain-0.2.0/plain/assets}/__init__.py +0 -0
- {plain-0.1.1 → plain-0.2.0}/plain/cli/README.md +0 -0
- {plain-0.1.1 → plain-0.2.0}/plain/cli/__init__.py +0 -0
- {plain-0.1.1 → plain-0.2.0}/plain/cli/formatting.py +0 -0
- {plain-0.1.1 → plain-0.2.0}/plain/cli/packages.py +0 -0
- {plain-0.1.1 → plain-0.2.0}/plain/cli/print.py +0 -0
- {plain-0.1.1 → plain-0.2.0}/plain/cli/startup.py +0 -0
- {plain-0.1.1 → plain-0.2.0}/plain/csrf/middleware.py +0 -0
- {plain-0.1.1 → plain-0.2.0}/plain/csrf/views.py +0 -0
- {plain-0.1.1 → plain-0.2.0}/plain/debug.py +0 -0
- {plain-0.1.1 → plain-0.2.0}/plain/exceptions.py +0 -0
- {plain-0.1.1 → plain-0.2.0}/plain/forms/README.md +0 -0
- {plain-0.1.1 → plain-0.2.0}/plain/forms/__init__.py +0 -0
- {plain-0.1.1 → plain-0.2.0}/plain/forms/boundfield.py +0 -0
- {plain-0.1.1 → plain-0.2.0}/plain/forms/exceptions.py +0 -0
- {plain-0.1.1 → plain-0.2.0}/plain/forms/fields.py +0 -0
- {plain-0.1.1 → plain-0.2.0}/plain/forms/forms.py +0 -0
- {plain-0.1.1 → plain-0.2.0}/plain/http/README.md +0 -0
- {plain-0.1.1 → plain-0.2.0}/plain/http/__init__.py +0 -0
- {plain-0.1.1 → plain-0.2.0}/plain/http/cookie.py +0 -0
- {plain-0.1.1 → plain-0.2.0}/plain/http/multipartparser.py +0 -0
- {plain-0.1.1 → plain-0.2.0}/plain/http/request.py +0 -0
- {plain-0.1.1 → plain-0.2.0}/plain/http/response.py +0 -0
- {plain-0.1.1/plain/internal/handlers → plain-0.2.0/plain/internal}/__init__.py +0 -0
- {plain-0.1.1 → plain-0.2.0}/plain/internal/files/README.md +0 -0
- {plain-0.1.1 → plain-0.2.0}/plain/internal/files/__init__.py +0 -0
- {plain-0.1.1 → plain-0.2.0}/plain/internal/files/base.py +0 -0
- {plain-0.1.1 → plain-0.2.0}/plain/internal/files/locks.py +0 -0
- {plain-0.1.1 → plain-0.2.0}/plain/internal/files/move.py +0 -0
- {plain-0.1.1 → plain-0.2.0}/plain/internal/files/temp.py +0 -0
- {plain-0.1.1 → plain-0.2.0}/plain/internal/files/uploadedfile.py +0 -0
- {plain-0.1.1 → plain-0.2.0}/plain/internal/files/uploadhandler.py +0 -0
- {plain-0.1.1 → plain-0.2.0}/plain/internal/files/utils.py +0 -0
- {plain-0.1.1/plain/internal/legacy → plain-0.2.0/plain/internal/handlers}/__init__.py +0 -0
- {plain-0.1.1 → plain-0.2.0}/plain/internal/handlers/base.py +0 -0
- {plain-0.1.1 → plain-0.2.0}/plain/internal/handlers/exception.py +0 -0
- {plain-0.1.1 → plain-0.2.0}/plain/internal/handlers/wsgi.py +0 -0
- {plain-0.1.1/plain/internal/legacy/management/commands → plain-0.2.0/plain/internal/legacy}/__init__.py +0 -0
- {plain-0.1.1 → plain-0.2.0}/plain/internal/legacy/__main__.py +0 -0
- {plain-0.1.1 → plain-0.2.0}/plain/internal/legacy/management/__init__.py +0 -0
- {plain-0.1.1 → plain-0.2.0}/plain/internal/legacy/management/base.py +0 -0
- {plain-0.1.1 → plain-0.2.0}/plain/internal/legacy/management/color.py +0 -0
- {plain-0.1.1 → plain-0.2.0}/plain/internal/legacy/management/sql.py +0 -0
- {plain-0.1.1 → plain-0.2.0}/plain/internal/legacy/management/utils.py +0 -0
- {plain-0.1.1 → plain-0.2.0}/plain/json.py +0 -0
- {plain-0.1.1 → plain-0.2.0}/plain/logs/README.md +0 -0
- {plain-0.1.1 → plain-0.2.0}/plain/logs/__init__.py +0 -0
- {plain-0.1.1 → plain-0.2.0}/plain/logs/configure.py +0 -0
- {plain-0.1.1 → plain-0.2.0}/plain/logs/loggers.py +0 -0
- {plain-0.1.1 → plain-0.2.0}/plain/logs/utils.py +0 -0
- {plain-0.1.1 → plain-0.2.0}/plain/middleware/README.md +0 -0
- {plain-0.1.1 → plain-0.2.0}/plain/middleware/__init__.py +0 -0
- {plain-0.1.1 → plain-0.2.0}/plain/middleware/clickjacking.py +0 -0
- {plain-0.1.1 → plain-0.2.0}/plain/middleware/common.py +0 -0
- {plain-0.1.1 → plain-0.2.0}/plain/middleware/gzip.py +0 -0
- {plain-0.1.1 → plain-0.2.0}/plain/middleware/security.py +0 -0
- {plain-0.1.1 → plain-0.2.0}/plain/packages/README.md +0 -0
- {plain-0.1.1 → plain-0.2.0}/plain/packages/__init__.py +0 -0
- {plain-0.1.1 → plain-0.2.0}/plain/paginator.py +0 -0
- {plain-0.1.1 → plain-0.2.0}/plain/preflight/README.md +0 -0
- {plain-0.1.1 → plain-0.2.0}/plain/preflight/__init__.py +0 -0
- {plain-0.1.1 → plain-0.2.0}/plain/preflight/compatibility/__init__.py +0 -0
- {plain-0.1.1 → plain-0.2.0}/plain/preflight/compatibility/django_4_0.py +0 -0
- {plain-0.1.1 → plain-0.2.0}/plain/preflight/files.py +0 -0
- {plain-0.1.1 → plain-0.2.0}/plain/preflight/messages.py +0 -0
- {plain-0.1.1 → plain-0.2.0}/plain/preflight/registry.py +0 -0
- {plain-0.1.1 → plain-0.2.0}/plain/preflight/security/__init__.py +0 -0
- {plain-0.1.1 → plain-0.2.0}/plain/preflight/security/base.py +0 -0
- {plain-0.1.1 → plain-0.2.0}/plain/preflight/security/csrf.py +0 -0
- {plain-0.1.1 → plain-0.2.0}/plain/signals/README.md +0 -0
- {plain-0.1.1 → plain-0.2.0}/plain/signals/__init__.py +0 -0
- {plain-0.1.1 → plain-0.2.0}/plain/signals/dispatch/__init__.py +0 -0
- {plain-0.1.1 → plain-0.2.0}/plain/signals/dispatch/dispatcher.py +0 -0
- {plain-0.1.1 → plain-0.2.0}/plain/signals/dispatch/license.txt +0 -0
- {plain-0.1.1 → plain-0.2.0}/plain/signing.py +0 -0
- {plain-0.1.1 → plain-0.2.0}/plain/templates/README.md +0 -0
- {plain-0.1.1 → plain-0.2.0}/plain/templates/__init__.py +0 -0
- {plain-0.1.1 → plain-0.2.0}/plain/templates/core.py +0 -0
- {plain-0.1.1 → plain-0.2.0}/plain/templates/jinja/README.md +0 -0
- {plain-0.1.1 → plain-0.2.0}/plain/templates/jinja/__init__.py +0 -0
- {plain-0.1.1 → plain-0.2.0}/plain/templates/jinja/defaults.py +0 -0
- {plain-0.1.1 → plain-0.2.0}/plain/templates/jinja/extensions.py +0 -0
- {plain-0.1.1 → plain-0.2.0}/plain/templates/jinja/filters.py +0 -0
- {plain-0.1.1 → plain-0.2.0}/plain/test/README.md +0 -0
- {plain-0.1.1 → plain-0.2.0}/plain/urls/README.md +0 -0
- {plain-0.1.1 → plain-0.2.0}/plain/urls/__init__.py +0 -0
- {plain-0.1.1 → plain-0.2.0}/plain/urls/base.py +0 -0
- {plain-0.1.1 → plain-0.2.0}/plain/urls/conf.py +0 -0
- {plain-0.1.1 → plain-0.2.0}/plain/urls/converters.py +0 -0
- {plain-0.1.1 → plain-0.2.0}/plain/urls/exceptions.py +0 -0
- {plain-0.1.1 → plain-0.2.0}/plain/urls/resolvers.py +0 -0
- {plain-0.1.1 → plain-0.2.0}/plain/utils/README.md +0 -0
- {plain-0.1.1 → plain-0.2.0}/plain/utils/__init__.py +0 -0
- {plain-0.1.1 → plain-0.2.0}/plain/utils/_os.py +0 -0
- {plain-0.1.1 → plain-0.2.0}/plain/utils/cache.py +0 -0
- {plain-0.1.1 → plain-0.2.0}/plain/utils/connection.py +0 -0
- {plain-0.1.1 → plain-0.2.0}/plain/utils/crypto.py +0 -0
- {plain-0.1.1 → plain-0.2.0}/plain/utils/datastructures.py +0 -0
- {plain-0.1.1 → plain-0.2.0}/plain/utils/dateformat.py +0 -0
- {plain-0.1.1 → plain-0.2.0}/plain/utils/dateparse.py +0 -0
- {plain-0.1.1 → plain-0.2.0}/plain/utils/dates.py +0 -0
- {plain-0.1.1 → plain-0.2.0}/plain/utils/deconstruct.py +0 -0
- {plain-0.1.1 → plain-0.2.0}/plain/utils/decorators.py +0 -0
- {plain-0.1.1 → plain-0.2.0}/plain/utils/deprecation.py +0 -0
- {plain-0.1.1 → plain-0.2.0}/plain/utils/duration.py +0 -0
- {plain-0.1.1 → plain-0.2.0}/plain/utils/email.py +0 -0
- {plain-0.1.1 → plain-0.2.0}/plain/utils/encoding.py +0 -0
- {plain-0.1.1 → plain-0.2.0}/plain/utils/functional.py +0 -0
- {plain-0.1.1 → plain-0.2.0}/plain/utils/hashable.py +0 -0
- {plain-0.1.1 → plain-0.2.0}/plain/utils/html.py +0 -0
- {plain-0.1.1 → plain-0.2.0}/plain/utils/http.py +0 -0
- {plain-0.1.1 → plain-0.2.0}/plain/utils/inspect.py +0 -0
- {plain-0.1.1 → plain-0.2.0}/plain/utils/ipv6.py +0 -0
- {plain-0.1.1 → plain-0.2.0}/plain/utils/itercompat.py +0 -0
- {plain-0.1.1 → plain-0.2.0}/plain/utils/module_loading.py +0 -0
- {plain-0.1.1 → plain-0.2.0}/plain/utils/regex_helper.py +0 -0
- {plain-0.1.1 → plain-0.2.0}/plain/utils/safestring.py +0 -0
- {plain-0.1.1 → plain-0.2.0}/plain/utils/termcolors.py +0 -0
- {plain-0.1.1 → plain-0.2.0}/plain/utils/text.py +0 -0
- {plain-0.1.1 → plain-0.2.0}/plain/utils/timesince.py +0 -0
- {plain-0.1.1 → plain-0.2.0}/plain/utils/timezone.py +0 -0
- {plain-0.1.1 → plain-0.2.0}/plain/utils/tree.py +0 -0
- {plain-0.1.1 → plain-0.2.0}/plain/validators.py +0 -0
- {plain-0.1.1 → plain-0.2.0}/plain/views/README.md +0 -0
- {plain-0.1.1 → plain-0.2.0}/plain/views/__init__.py +0 -0
- {plain-0.1.1 → plain-0.2.0}/plain/views/csrf.py +0 -0
- {plain-0.1.1 → plain-0.2.0}/plain/views/exceptions.py +0 -0
- {plain-0.1.1 → plain-0.2.0}/plain/views/forms.py +0 -0
- {plain-0.1.1 → plain-0.2.0}/plain/views/objects.py +0 -0
- {plain-0.1.1 → plain-0.2.0}/plain/views/redirect.py +0 -0
- {plain-0.1.1 → plain-0.2.0}/plain/views/templates.py +0 -0
- {plain-0.1.1 → plain-0.2.0}/plain/wsgi.py +0 -0
@@ -59,27 +59,3 @@ LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
|
|
59
59
|
ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
60
60
|
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
|
61
61
|
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
62
|
-
|
63
|
-
|
64
|
-
## This package contains code forked from github.com/evansd/whitenoise
|
65
|
-
|
66
|
-
The MIT License (MIT)
|
67
|
-
|
68
|
-
Copyright (c) 2013 David Evans
|
69
|
-
|
70
|
-
Permission is hereby granted, free of charge, to any person obtaining a copy of
|
71
|
-
this software and associated documentation files (the "Software"), to deal in
|
72
|
-
the Software without restriction, including without limitation the rights to
|
73
|
-
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
|
74
|
-
the Software, and to permit persons to whom the Software is furnished to do so,
|
75
|
-
subject to the following conditions:
|
76
|
-
|
77
|
-
The above copyright notice and this permission notice shall be included in all
|
78
|
-
copies or substantial portions of the Software.
|
79
|
-
|
80
|
-
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
81
|
-
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
82
|
-
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
83
|
-
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
84
|
-
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
|
85
|
-
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
@@ -0,0 +1,37 @@
|
|
1
|
+
# Assets
|
2
|
+
|
3
|
+
Serve static assets (CSS, JS, images, etc.) directly from your app.
|
4
|
+
|
5
|
+
The default behavior is for Plain to serve its own assets through a view. This behaves in a way similar to [Whitenoise](https://whitenoise.readthedocs.io/en/latest/).
|
6
|
+
|
7
|
+
## Usage
|
8
|
+
|
9
|
+
To include assests in your app, put them either in `app/assets` or `app/<package>/assets`.
|
10
|
+
|
11
|
+
Then include the `plain.assets.urls` in your `urls.py`:
|
12
|
+
|
13
|
+
```python
|
14
|
+
# app/urls.py
|
15
|
+
from plain.urls import include, path
|
16
|
+
import plain.assets.urls
|
17
|
+
|
18
|
+
|
19
|
+
urlpatterns = [
|
20
|
+
path("assets/", include(plain.assets.urls)),
|
21
|
+
# ...
|
22
|
+
]
|
23
|
+
```
|
24
|
+
|
25
|
+
Then in your template you can use the `asset()` function to get the URL.
|
26
|
+
|
27
|
+
```html
|
28
|
+
<link rel="stylesheet" href="{{ asset('css/style.css') }}">
|
29
|
+
```
|
30
|
+
|
31
|
+
If you ever need to reference an asset directly in Python code, you can use the `get_asset_url()` function.
|
32
|
+
|
33
|
+
```python
|
34
|
+
from plain.assets.urls import get_asset_url
|
35
|
+
|
36
|
+
print(get_asset_url("css/style.css"))
|
37
|
+
```
|
@@ -0,0 +1,111 @@
|
|
1
|
+
import gzip
|
2
|
+
import hashlib
|
3
|
+
import os
|
4
|
+
import shutil
|
5
|
+
|
6
|
+
from .finders import find_assets
|
7
|
+
from .fingerprints import AssetsFingerprintsManifest
|
8
|
+
|
9
|
+
FINGERPRINT_LENGTH = 7
|
10
|
+
|
11
|
+
SKIP_COMPRESS_EXTENSIONS = (
|
12
|
+
# Images
|
13
|
+
"jpg",
|
14
|
+
"jpeg",
|
15
|
+
"png",
|
16
|
+
"gif",
|
17
|
+
"webp",
|
18
|
+
# Compressed files
|
19
|
+
"zip",
|
20
|
+
"gz",
|
21
|
+
"tgz",
|
22
|
+
"bz2",
|
23
|
+
"tbz",
|
24
|
+
"xz",
|
25
|
+
"br",
|
26
|
+
# Fonts
|
27
|
+
"woff",
|
28
|
+
"woff2",
|
29
|
+
# Video
|
30
|
+
"3gp",
|
31
|
+
"3gpp",
|
32
|
+
"asf",
|
33
|
+
"avi",
|
34
|
+
"m4v",
|
35
|
+
"mov",
|
36
|
+
"mp4",
|
37
|
+
"mpeg",
|
38
|
+
"mpg",
|
39
|
+
"webm",
|
40
|
+
"wmv",
|
41
|
+
)
|
42
|
+
|
43
|
+
|
44
|
+
def compile_assets(*, target_dir, keep_original, fingerprint, compress):
|
45
|
+
manifest = AssetsFingerprintsManifest()
|
46
|
+
|
47
|
+
for url_path, asset in find_assets().items():
|
48
|
+
resolved_path, compiled_paths = compile_asset(
|
49
|
+
asset=asset,
|
50
|
+
target_dir=target_dir,
|
51
|
+
keep_original=keep_original,
|
52
|
+
fingerprint=fingerprint,
|
53
|
+
compress=compress,
|
54
|
+
)
|
55
|
+
if resolved_path != url_path:
|
56
|
+
manifest[url_path] = resolved_path
|
57
|
+
|
58
|
+
yield url_path, resolved_path, compiled_paths
|
59
|
+
|
60
|
+
if manifest:
|
61
|
+
manifest.save()
|
62
|
+
|
63
|
+
|
64
|
+
def compile_asset(*, asset, target_dir, keep_original, fingerprint, compress):
|
65
|
+
"""
|
66
|
+
Compile an asset to multiple output paths.
|
67
|
+
"""
|
68
|
+
compiled_paths = []
|
69
|
+
|
70
|
+
# The expected destination for the original asset
|
71
|
+
target_path = os.path.join(target_dir, asset.url_path)
|
72
|
+
|
73
|
+
# Keep track of where the final, resolved asset ends up
|
74
|
+
resolved_url_path = asset.url_path
|
75
|
+
|
76
|
+
# Make sure all the expected directories exist
|
77
|
+
os.makedirs(os.path.dirname(target_path), exist_ok=True)
|
78
|
+
|
79
|
+
base, extension = os.path.splitext(asset.url_path)
|
80
|
+
|
81
|
+
# First, copy the original asset over
|
82
|
+
if keep_original:
|
83
|
+
shutil.copy(asset.absolute_path, target_path)
|
84
|
+
compiled_paths.append(target_path)
|
85
|
+
|
86
|
+
if fingerprint:
|
87
|
+
# Fingerprint it with an md5 hash
|
88
|
+
# (maybe need a setting with fnmatch patterns for files to NOT fingerprint?
|
89
|
+
# that would allow pre-fingerprinted files to be used as-is, and keep source maps etc in tact)
|
90
|
+
with open(asset.absolute_path, "rb") as f:
|
91
|
+
content = f.read()
|
92
|
+
fingerprint_hash = hashlib.md5(content, usedforsecurity=False).hexdigest()[
|
93
|
+
:FINGERPRINT_LENGTH
|
94
|
+
]
|
95
|
+
|
96
|
+
fingerprinted_basename = f"{base}.{fingerprint_hash}{extension}"
|
97
|
+
fingerprinted_path = os.path.join(target_dir, fingerprinted_basename)
|
98
|
+
shutil.copy(asset.absolute_path, fingerprinted_path)
|
99
|
+
compiled_paths.append(fingerprinted_path)
|
100
|
+
|
101
|
+
resolved_url_path = os.path.relpath(fingerprinted_path, target_dir)
|
102
|
+
|
103
|
+
if compress and extension not in SKIP_COMPRESS_EXTENSIONS:
|
104
|
+
for path in compiled_paths.copy():
|
105
|
+
gzip_path = f"{path}.gz"
|
106
|
+
with gzip.GzipFile(gzip_path, "wb") as f:
|
107
|
+
with open(path, "rb") as f2:
|
108
|
+
f.write(f2.read())
|
109
|
+
compiled_paths.append(gzip_path)
|
110
|
+
|
111
|
+
return resolved_url_path, compiled_paths
|
@@ -0,0 +1,41 @@
|
|
1
|
+
import os
|
2
|
+
|
3
|
+
from plain.packages import packages
|
4
|
+
from plain.runtime import APP_PATH
|
5
|
+
|
6
|
+
APP_ASSETS_DIR = APP_PATH / "assets"
|
7
|
+
|
8
|
+
SKIP_ASSETS = (".DS_Store", ".gitignore")
|
9
|
+
|
10
|
+
|
11
|
+
def find_assets():
|
12
|
+
assets_map = {}
|
13
|
+
|
14
|
+
class Asset:
|
15
|
+
def __init__(self, *, url_path, absolute_path):
|
16
|
+
self.url_path = url_path
|
17
|
+
self.absolute_path = absolute_path
|
18
|
+
|
19
|
+
def __str__(self):
|
20
|
+
return self.url_path
|
21
|
+
|
22
|
+
def iter_directory(path):
|
23
|
+
for root, _, files in os.walk(path):
|
24
|
+
for f in files:
|
25
|
+
if f in SKIP_ASSETS:
|
26
|
+
continue
|
27
|
+
abs_path = os.path.join(root, f)
|
28
|
+
url_path = os.path.relpath(abs_path, path)
|
29
|
+
yield url_path, abs_path
|
30
|
+
|
31
|
+
# Iterate the installed package assets, in order
|
32
|
+
for pkg in packages.get_package_configs():
|
33
|
+
pkg_assets_dir = os.path.join(pkg.path, "assets")
|
34
|
+
for url_path, abs_path in iter_directory(pkg_assets_dir):
|
35
|
+
assets_map[url_path] = Asset(url_path=url_path, absolute_path=abs_path)
|
36
|
+
|
37
|
+
# The app/assets take priority over everything
|
38
|
+
for url_path, abs_path in iter_directory(APP_ASSETS_DIR):
|
39
|
+
assets_map[url_path] = Asset(url_path=url_path, absolute_path=abs_path)
|
40
|
+
|
41
|
+
return assets_map
|
@@ -0,0 +1,38 @@
|
|
1
|
+
import json
|
2
|
+
from functools import cache
|
3
|
+
|
4
|
+
from plain.runtime import settings
|
5
|
+
|
6
|
+
|
7
|
+
class AssetsFingerprintsManifest(dict):
|
8
|
+
def __init__(self):
|
9
|
+
self.path = settings.PLAIN_TEMP_PATH / "assets" / "fingerprints.json"
|
10
|
+
|
11
|
+
def load(self):
|
12
|
+
if self.path.exists():
|
13
|
+
with open(self.path) as f:
|
14
|
+
self.update(json.load(f))
|
15
|
+
|
16
|
+
def save(self):
|
17
|
+
with open(self.path, "w") as f:
|
18
|
+
json.dump(self, f, indent=2)
|
19
|
+
|
20
|
+
|
21
|
+
@cache
|
22
|
+
def _get_manifest():
|
23
|
+
"""
|
24
|
+
A cached function for loading the asset fingerprints manifest,
|
25
|
+
so we don't have to keep loading it from disk over and over.
|
26
|
+
"""
|
27
|
+
manifest = AssetsFingerprintsManifest()
|
28
|
+
manifest.load()
|
29
|
+
return manifest
|
30
|
+
|
31
|
+
|
32
|
+
def get_fingerprinted_url_path(url_path):
|
33
|
+
"""
|
34
|
+
Get the final fingerprinted path for an asset URL path.
|
35
|
+
"""
|
36
|
+
manifest = _get_manifest()
|
37
|
+
if url_path in manifest:
|
38
|
+
return manifest[url_path]
|
@@ -0,0 +1,31 @@
|
|
1
|
+
from plain.runtime import settings
|
2
|
+
from plain.urls import path, reverse
|
3
|
+
|
4
|
+
from .fingerprints import get_fingerprinted_url_path
|
5
|
+
from .views import AssetView
|
6
|
+
|
7
|
+
default_namespace = "assets"
|
8
|
+
|
9
|
+
|
10
|
+
def get_asset_url(url_path):
|
11
|
+
if settings.DEBUG:
|
12
|
+
# In debug, we only ever use the original URL path.
|
13
|
+
resolved_url_path = url_path
|
14
|
+
else:
|
15
|
+
# If a fingerprinted URL path is available, use that.
|
16
|
+
if fingerprinted_url_path := get_fingerprinted_url_path(url_path):
|
17
|
+
resolved_url_path = fingerprinted_url_path
|
18
|
+
else:
|
19
|
+
resolved_url_path = url_path
|
20
|
+
|
21
|
+
# If a base url is set (i.e. a CDN),
|
22
|
+
# then do a simple join to get the full URL.
|
23
|
+
if settings.ASSETS_BASE_URL:
|
24
|
+
return settings.ASSETS_BASE_URL + resolved_url_path
|
25
|
+
|
26
|
+
return reverse(default_namespace + ":asset", kwargs={"path": resolved_url_path})
|
27
|
+
|
28
|
+
|
29
|
+
urlpatterns = [
|
30
|
+
path("<path:path>", AssetView, name="asset"),
|
31
|
+
]
|
@@ -0,0 +1,263 @@
|
|
1
|
+
import functools
|
2
|
+
import os
|
3
|
+
from email.utils import formatdate, parsedate
|
4
|
+
from io import BytesIO
|
5
|
+
|
6
|
+
from plain.http import (
|
7
|
+
FileResponse,
|
8
|
+
Http404,
|
9
|
+
Response,
|
10
|
+
ResponseNotModified,
|
11
|
+
ResponseRedirect,
|
12
|
+
StreamingResponse,
|
13
|
+
)
|
14
|
+
from plain.runtime import settings
|
15
|
+
from plain.urls import reverse
|
16
|
+
from plain.views import View
|
17
|
+
|
18
|
+
from .compile import FINGERPRINT_LENGTH
|
19
|
+
from .finders import find_assets
|
20
|
+
from .fingerprints import get_fingerprinted_url_path
|
21
|
+
|
22
|
+
|
23
|
+
class AssetView(View):
|
24
|
+
"""
|
25
|
+
Serve an asset file directly.
|
26
|
+
|
27
|
+
This class could be subclassed to further tweak the responses or behavior.
|
28
|
+
"""
|
29
|
+
|
30
|
+
def get(self):
|
31
|
+
url_path = self.url_kwargs["path"]
|
32
|
+
|
33
|
+
# Make a trailing slash work, but we don't expect it
|
34
|
+
url_path = url_path.rstrip("/")
|
35
|
+
|
36
|
+
if settings.DEBUG and False:
|
37
|
+
absolute_path = self.get_debug_asset_path(url_path)
|
38
|
+
else:
|
39
|
+
absolute_path = self.get_asset_path(url_path)
|
40
|
+
|
41
|
+
if settings.ASSETS_REDIRECT_ORIGINAL:
|
42
|
+
if redirect_response := self.get_redirect_response(url_path):
|
43
|
+
return redirect_response
|
44
|
+
|
45
|
+
self.check_asset_path(absolute_path)
|
46
|
+
|
47
|
+
if encoded_path := self.get_encoded_path(absolute_path):
|
48
|
+
absolute_path = encoded_path
|
49
|
+
|
50
|
+
if range_response := self.get_range_response(absolute_path):
|
51
|
+
return range_response
|
52
|
+
|
53
|
+
if not_modified_response := self.get_conditional_response(absolute_path):
|
54
|
+
return not_modified_response
|
55
|
+
|
56
|
+
response = FileResponse(
|
57
|
+
open(absolute_path, "rb"),
|
58
|
+
filename=os.path.basename(absolute_path), # Used for Content-Type
|
59
|
+
)
|
60
|
+
response.headers = self.update_headers(response.headers, absolute_path)
|
61
|
+
return response
|
62
|
+
|
63
|
+
def get_asset_path(self, path):
|
64
|
+
"""Get the path to the compiled asset"""
|
65
|
+
compiled_path = os.path.abspath(settings.ASSETS_COMPILED_PATH)
|
66
|
+
asset_path = os.path.join(compiled_path, path)
|
67
|
+
|
68
|
+
# Make sure we don't try to escape the compiled assests path
|
69
|
+
if not os.path.commonpath([compiled_path, asset_path]) == compiled_path:
|
70
|
+
raise Http404("Asset not found")
|
71
|
+
|
72
|
+
return asset_path
|
73
|
+
|
74
|
+
def get_debug_asset_path(self, path):
|
75
|
+
"""Make a "live" check to find the uncompiled asset in the filesystem"""
|
76
|
+
if asset := find_assets().get(path):
|
77
|
+
return asset.absolute_path
|
78
|
+
|
79
|
+
def check_asset_path(self, path):
|
80
|
+
if not path:
|
81
|
+
raise Http404("Asset not found")
|
82
|
+
|
83
|
+
if not os.path.exists(path):
|
84
|
+
raise Http404("Asset not found")
|
85
|
+
|
86
|
+
if os.path.isdir(path):
|
87
|
+
raise Http404("Asset is a directory")
|
88
|
+
|
89
|
+
@functools.cache
|
90
|
+
def get_last_modified(self, path):
|
91
|
+
try:
|
92
|
+
mtime = os.path.getmtime(path)
|
93
|
+
except OSError:
|
94
|
+
mtime = None
|
95
|
+
|
96
|
+
if mtime:
|
97
|
+
return formatdate(mtime, usegmt=True)
|
98
|
+
|
99
|
+
@functools.cache
|
100
|
+
def get_etag(self, path):
|
101
|
+
try:
|
102
|
+
mtime = os.path.getmtime(path)
|
103
|
+
except OSError:
|
104
|
+
mtime = None
|
105
|
+
|
106
|
+
timestamp = int(mtime)
|
107
|
+
size = self.get_size(path)
|
108
|
+
return f'"{timestamp:x}-{size:x}"'
|
109
|
+
|
110
|
+
@functools.cache
|
111
|
+
def get_size(self, path):
|
112
|
+
return os.path.getsize(path)
|
113
|
+
|
114
|
+
def update_headers(self, headers, path):
|
115
|
+
headers.setdefault("Access-Control-Allow-Origin", "*")
|
116
|
+
|
117
|
+
# Always vary on Accept-Encoding
|
118
|
+
vary = headers.get("Vary")
|
119
|
+
if not vary:
|
120
|
+
headers["Vary"] = "Accept-Encoding"
|
121
|
+
elif vary == "*":
|
122
|
+
pass
|
123
|
+
elif "Accept-Encoding" not in vary:
|
124
|
+
headers["Vary"] = vary + ", Accept-Encoding"
|
125
|
+
|
126
|
+
# If the file is compressed, tell the browser
|
127
|
+
if path.endswith(".gz"):
|
128
|
+
headers.setdefault("Content-Encoding", "gzip")
|
129
|
+
elif path.endswith(".br"):
|
130
|
+
headers.setdefault("Content-Encoding", "br")
|
131
|
+
|
132
|
+
is_immutable = self.is_immutable(path)
|
133
|
+
|
134
|
+
if is_immutable:
|
135
|
+
max_age = 10 * 365 * 24 * 60 * 60 # 10 years
|
136
|
+
headers.setdefault("Cache-Control", f"max-age={max_age}, immutable")
|
137
|
+
elif settings.DEBUG:
|
138
|
+
# In development, cache for 1 second to avoid re-fetching the same file
|
139
|
+
headers.setdefault("Cache-Control", "max-age=0")
|
140
|
+
else:
|
141
|
+
# Tell the browser to cache the file for 60 seconds if nothing else
|
142
|
+
headers.setdefault("Cache-Control", "max-age=60")
|
143
|
+
|
144
|
+
if not is_immutable:
|
145
|
+
if last_modified := self.get_last_modified(path):
|
146
|
+
headers.setdefault("Last-Modified", last_modified)
|
147
|
+
if etag := self.get_etag(path):
|
148
|
+
headers.setdefault("ETag", etag)
|
149
|
+
|
150
|
+
return headers
|
151
|
+
|
152
|
+
def is_immutable(self, path):
|
153
|
+
"""
|
154
|
+
Determine whether an asset looks like it is immutable.
|
155
|
+
|
156
|
+
Pattern matching based on fingerprinted filenames:
|
157
|
+
- main.{fingerprint}.css
|
158
|
+
- main.{fingerprint}.css.gz
|
159
|
+
"""
|
160
|
+
base = os.path.basename(path)
|
161
|
+
extension = None
|
162
|
+
while extension != "":
|
163
|
+
base, extension = os.path.splitext(base)
|
164
|
+
if len(extension) == FINGERPRINT_LENGTH + 1 and extension[1:].isalnum():
|
165
|
+
return True
|
166
|
+
|
167
|
+
return False
|
168
|
+
|
169
|
+
def get_encoded_path(self, path):
|
170
|
+
"""
|
171
|
+
If the client supports compression, return the path to the compressed file.
|
172
|
+
Otherwise, return the original path.
|
173
|
+
"""
|
174
|
+
accept_encoding = self.request.headers.get("Accept-Encoding")
|
175
|
+
if not accept_encoding:
|
176
|
+
return
|
177
|
+
|
178
|
+
if "br" in accept_encoding:
|
179
|
+
br_path = path + ".br"
|
180
|
+
if os.path.exists(br_path):
|
181
|
+
return br_path
|
182
|
+
|
183
|
+
if "gzip" in accept_encoding:
|
184
|
+
gzip_path = path + ".gz"
|
185
|
+
if os.path.exists(gzip_path):
|
186
|
+
return gzip_path
|
187
|
+
|
188
|
+
def get_redirect_response(self, path):
|
189
|
+
"""If the asset is not found, try to redirect to the fingerprinted path"""
|
190
|
+
fingerprinted_url_path = get_fingerprinted_url_path(path)
|
191
|
+
|
192
|
+
if not fingerprinted_url_path or fingerprinted_url_path == path:
|
193
|
+
# Don't need to redirect if there is no fingerprinted path,
|
194
|
+
# or we're already looking at it.
|
195
|
+
return
|
196
|
+
|
197
|
+
from .urls import default_namespace
|
198
|
+
|
199
|
+
return ResponseRedirect(
|
200
|
+
redirect_to=reverse(
|
201
|
+
f"{default_namespace}:asset", args=[fingerprinted_url_path]
|
202
|
+
),
|
203
|
+
headers={
|
204
|
+
"Cache-Control": "max-age=60", # Can cache this for a short time, but the fingerprinted path can change
|
205
|
+
},
|
206
|
+
)
|
207
|
+
|
208
|
+
def get_conditional_response(self, path):
|
209
|
+
"""
|
210
|
+
Support conditional requests (HTTP 304 response) based on ETag and Last-Modified headers.
|
211
|
+
"""
|
212
|
+
if self.request.headers.get("If-None-Match") == self.get_etag(path):
|
213
|
+
response = ResponseNotModified()
|
214
|
+
response.headers = self.update_headers(response.headers, path)
|
215
|
+
return response
|
216
|
+
|
217
|
+
if "If-Modified-Since" in self.request.headers:
|
218
|
+
if_modified_since = parsedate(self.request.headers["If-Modified-Since"])
|
219
|
+
last_modified = parsedate(self.get_last_modified(path))
|
220
|
+
if (
|
221
|
+
if_modified_since
|
222
|
+
and last_modified
|
223
|
+
and if_modified_since >= last_modified
|
224
|
+
):
|
225
|
+
response = ResponseNotModified()
|
226
|
+
response.headers = self.update_headers(response.headers, path)
|
227
|
+
return response
|
228
|
+
|
229
|
+
def get_range_response(self, path):
|
230
|
+
"""
|
231
|
+
Support range requests (HTTP 206 response).
|
232
|
+
"""
|
233
|
+
range_header = self.request.headers.get("HTTP_RANGE")
|
234
|
+
if not range_header:
|
235
|
+
return None
|
236
|
+
|
237
|
+
file_size = self.get_size(path)
|
238
|
+
|
239
|
+
if not range_header.startswith("bytes="):
|
240
|
+
return Response(
|
241
|
+
status=416, headers=[("Content-Range", f"bytes */{file_size}")]
|
242
|
+
)
|
243
|
+
|
244
|
+
range_values = range_header.split("=")[1].split("-")
|
245
|
+
start = int(range_values[0]) if range_values[0] else 0
|
246
|
+
end = int(range_values[1]) if range_values[1] else float("inf")
|
247
|
+
|
248
|
+
if start >= file_size:
|
249
|
+
return Response(
|
250
|
+
status=416, headers=[("Content-Range", f"bytes */{file_size}")]
|
251
|
+
)
|
252
|
+
|
253
|
+
end = min(end, file_size - 1)
|
254
|
+
|
255
|
+
with open(path, "rb") as f:
|
256
|
+
f.seek(start)
|
257
|
+
content = f.read(end - start + 1)
|
258
|
+
|
259
|
+
response = StreamingResponse(BytesIO(content), status=206)
|
260
|
+
response.headers = self.update_headers(response.headers, path)
|
261
|
+
response.headers["Content-Range"] = f"bytes {start}-{end}/{file_size}"
|
262
|
+
response.headers["Content-Length"] = str(end - start + 1)
|
263
|
+
return response
|
@@ -1,6 +1,7 @@
|
|
1
1
|
import importlib
|
2
2
|
import json
|
3
3
|
import os
|
4
|
+
import shutil
|
4
5
|
import subprocess
|
5
6
|
import sys
|
6
7
|
import traceback
|
@@ -12,7 +13,9 @@ from click.core import Command, Context
|
|
12
13
|
|
13
14
|
import plain.runtime
|
14
15
|
from plain import preflight
|
16
|
+
from plain.assets.compile import compile_assets
|
15
17
|
from plain.packages import packages
|
18
|
+
from plain.runtime import settings
|
16
19
|
|
17
20
|
from .formatting import PlainContext
|
18
21
|
from .packages import EntryPointGroup, InstalledPackagesGroup
|
@@ -270,16 +273,44 @@ def preflight_checks(package_label, deploy, fail_level, databases):
|
|
270
273
|
|
271
274
|
|
272
275
|
@plain_cli.command()
|
273
|
-
@click.
|
274
|
-
|
276
|
+
@click.option(
|
277
|
+
"--keep-original/--no-keep-original",
|
278
|
+
"keep_original",
|
279
|
+
is_flag=True,
|
280
|
+
default=False,
|
281
|
+
help="Keep the original assets",
|
282
|
+
)
|
283
|
+
@click.option(
|
284
|
+
"--fingerprint/--no-fingerprint",
|
285
|
+
"fingerprint",
|
286
|
+
is_flag=True,
|
287
|
+
default=True,
|
288
|
+
help="Fingerprint the assets",
|
289
|
+
)
|
290
|
+
@click.option(
|
291
|
+
"--compress/--no-compress",
|
292
|
+
"compress",
|
293
|
+
is_flag=True,
|
294
|
+
default=True,
|
295
|
+
help="Compress the assets",
|
296
|
+
)
|
297
|
+
def compile(keep_original, fingerprint, compress):
|
275
298
|
"""Compile static assets"""
|
276
299
|
|
277
|
-
|
300
|
+
if not keep_original and not fingerprint:
|
301
|
+
click.secho(
|
302
|
+
"You must either keep the original assets or fingerprint them.",
|
303
|
+
fg="red",
|
304
|
+
err=True,
|
305
|
+
)
|
306
|
+
sys.exit(1)
|
278
307
|
|
279
308
|
# TODO make this an entrypoint instead
|
280
309
|
# Compile our Tailwind CSS (including templates in plain itself)
|
281
310
|
if find_spec("plain.tailwind") is not None:
|
311
|
+
click.secho("Compiling Tailwind CSS", bold=True)
|
282
312
|
result = subprocess.run(["plain", "tailwind", "compile", "--minify"])
|
313
|
+
print()
|
283
314
|
if result.returncode:
|
284
315
|
click.secho(
|
285
316
|
f"Error compiling Tailwind CSS (exit {result.returncode})", fg="red"
|
@@ -295,15 +326,47 @@ def compile(ctx):
|
|
295
326
|
package = json.load(f)
|
296
327
|
|
297
328
|
if package.get("scripts", {}).get("compile"):
|
329
|
+
click.secho("Running `npm run compile`", bold=True)
|
298
330
|
result = subprocess.run(["npm", "run", "compile"])
|
331
|
+
print()
|
299
332
|
if result.returncode:
|
300
333
|
click.secho(
|
301
334
|
f"Error in `npm run compile` (exit {result.returncode})", fg="red"
|
302
335
|
)
|
303
336
|
sys.exit(result.returncode)
|
304
337
|
|
305
|
-
#
|
306
|
-
|
338
|
+
# Compile our assets
|
339
|
+
compiled_target_dir = settings.ASSETS_COMPILED_PATH
|
340
|
+
click.secho(f"Compiling assets to {compiled_target_dir}", bold=True)
|
341
|
+
if compiled_target_dir.exists():
|
342
|
+
click.secho("(clearing previously compiled assets)")
|
343
|
+
shutil.rmtree(compiled_target_dir)
|
344
|
+
compiled_target_dir.mkdir(parents=True, exist_ok=True)
|
345
|
+
|
346
|
+
total_files = 0
|
347
|
+
total_compiled = 0
|
348
|
+
|
349
|
+
for url_path, resolved_url_path, compiled_paths in compile_assets(
|
350
|
+
target_dir=compiled_target_dir,
|
351
|
+
keep_original=keep_original,
|
352
|
+
fingerprint=fingerprint,
|
353
|
+
compress=compress,
|
354
|
+
):
|
355
|
+
if url_path == resolved_url_path:
|
356
|
+
click.secho(url_path, bold=True)
|
357
|
+
else:
|
358
|
+
click.secho(url_path, bold=True, nl=False)
|
359
|
+
click.secho(" → ", fg="yellow", nl=False)
|
360
|
+
click.echo(resolved_url_path)
|
361
|
+
|
362
|
+
print("\n".join(f" {Path(p).relative_to(Path.cwd())}" for p in compiled_paths))
|
363
|
+
|
364
|
+
total_files += 1
|
365
|
+
total_compiled += len(compiled_paths)
|
366
|
+
|
367
|
+
click.secho(
|
368
|
+
f"Compiled {total_files} assets into {total_compiled} files", fg="green"
|
369
|
+
)
|
307
370
|
|
308
371
|
|
309
372
|
@plain_cli.command()
|