plain 0.1.2__tar.gz → 0.2.1__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.2 → plain-0.2.1}/LICENSE +0 -24
- {plain-0.1.2 → plain-0.2.1}/PKG-INFO +1 -1
- plain-0.2.1/plain/assets/README.md +94 -0
- plain-0.2.1/plain/assets/compile.py +121 -0
- plain-0.2.1/plain/assets/finders.py +41 -0
- plain-0.2.1/plain/assets/fingerprints.py +38 -0
- plain-0.2.1/plain/assets/urls.py +31 -0
- plain-0.2.1/plain/assets/views.py +263 -0
- {plain-0.1.2 → plain-0.2.1}/plain/cli/cli.py +67 -5
- {plain-0.1.2 → plain-0.2.1}/plain/packages/config.py +5 -5
- {plain-0.1.2 → plain-0.2.1}/plain/packages/registry.py +1 -7
- {plain-0.1.2 → plain-0.2.1}/plain/preflight/urls.py +0 -10
- {plain-0.1.2 → plain-0.2.1}/plain/runtime/README.md +0 -1
- {plain-0.1.2 → plain-0.2.1}/plain/runtime/global_settings.py +5 -16
- {plain-0.1.2 → plain-0.2.1}/plain/runtime/user_settings.py +0 -49
- {plain-0.1.2 → plain-0.2.1}/plain/templates/jinja/globals.py +1 -1
- plain-0.2.1/plain/test/__init__.py +8 -0
- {plain-0.1.2 → plain-0.2.1}/plain/test/client.py +36 -16
- {plain-0.1.2 → plain-0.2.1}/plain/views/base.py +5 -3
- {plain-0.1.2 → plain-0.2.1}/plain/views/errors.py +7 -0
- {plain-0.1.2 → plain-0.2.1}/pyproject.toml +1 -1
- plain-0.1.2/plain/assets/README.md +0 -56
- plain-0.1.2/plain/assets/__init__.py +0 -6
- plain-0.1.2/plain/assets/finders.py +0 -233
- plain-0.1.2/plain/assets/preflight.py +0 -14
- plain-0.1.2/plain/assets/storage.py +0 -916
- plain-0.1.2/plain/assets/utils.py +0 -52
- plain-0.1.2/plain/assets/whitenoise/__init__.py +0 -5
- plain-0.1.2/plain/assets/whitenoise/base.py +0 -259
- plain-0.1.2/plain/assets/whitenoise/compress.py +0 -189
- plain-0.1.2/plain/assets/whitenoise/media_types.py +0 -137
- plain-0.1.2/plain/assets/whitenoise/middleware.py +0 -197
- plain-0.1.2/plain/assets/whitenoise/responders.py +0 -286
- plain-0.1.2/plain/assets/whitenoise/storage.py +0 -178
- plain-0.1.2/plain/assets/whitenoise/string_utils.py +0 -13
- plain-0.1.2/plain/internal/legacy/management/commands/collectstatic.py +0 -297
- plain-0.1.2/plain/test/__init__.py +0 -16
- plain-0.1.2/plain/test/utils.py +0 -255
- {plain-0.1.2 → plain-0.2.1}/README.md +0 -0
- {plain-0.1.2 → plain-0.2.1}/plain/README.md +0 -0
- {plain-0.1.2 → plain-0.2.1}/plain/__main__.py +0 -0
- {plain-0.1.2/plain/internal → plain-0.2.1/plain/assets}/__init__.py +0 -0
- {plain-0.1.2 → plain-0.2.1}/plain/cli/README.md +0 -0
- {plain-0.1.2 → plain-0.2.1}/plain/cli/__init__.py +0 -0
- {plain-0.1.2 → plain-0.2.1}/plain/cli/formatting.py +0 -0
- {plain-0.1.2 → plain-0.2.1}/plain/cli/packages.py +0 -0
- {plain-0.1.2 → plain-0.2.1}/plain/cli/print.py +0 -0
- {plain-0.1.2 → plain-0.2.1}/plain/cli/startup.py +0 -0
- {plain-0.1.2 → plain-0.2.1}/plain/csrf/README.md +0 -0
- {plain-0.1.2 → plain-0.2.1}/plain/csrf/middleware.py +0 -0
- {plain-0.1.2 → plain-0.2.1}/plain/csrf/views.py +0 -0
- {plain-0.1.2 → plain-0.2.1}/plain/debug.py +0 -0
- {plain-0.1.2 → plain-0.2.1}/plain/exceptions.py +0 -0
- {plain-0.1.2 → plain-0.2.1}/plain/forms/README.md +0 -0
- {plain-0.1.2 → plain-0.2.1}/plain/forms/__init__.py +0 -0
- {plain-0.1.2 → plain-0.2.1}/plain/forms/boundfield.py +0 -0
- {plain-0.1.2 → plain-0.2.1}/plain/forms/exceptions.py +0 -0
- {plain-0.1.2 → plain-0.2.1}/plain/forms/fields.py +0 -0
- {plain-0.1.2 → plain-0.2.1}/plain/forms/forms.py +0 -0
- {plain-0.1.2 → plain-0.2.1}/plain/http/README.md +0 -0
- {plain-0.1.2 → plain-0.2.1}/plain/http/__init__.py +0 -0
- {plain-0.1.2 → plain-0.2.1}/plain/http/cookie.py +0 -0
- {plain-0.1.2 → plain-0.2.1}/plain/http/multipartparser.py +0 -0
- {plain-0.1.2 → plain-0.2.1}/plain/http/request.py +0 -0
- {plain-0.1.2 → plain-0.2.1}/plain/http/response.py +0 -0
- {plain-0.1.2/plain/internal/handlers → plain-0.2.1/plain/internal}/__init__.py +0 -0
- {plain-0.1.2 → plain-0.2.1}/plain/internal/files/README.md +0 -0
- {plain-0.1.2 → plain-0.2.1}/plain/internal/files/__init__.py +0 -0
- {plain-0.1.2 → plain-0.2.1}/plain/internal/files/base.py +0 -0
- {plain-0.1.2 → plain-0.2.1}/plain/internal/files/locks.py +0 -0
- {plain-0.1.2 → plain-0.2.1}/plain/internal/files/move.py +0 -0
- {plain-0.1.2 → plain-0.2.1}/plain/internal/files/temp.py +0 -0
- {plain-0.1.2 → plain-0.2.1}/plain/internal/files/uploadedfile.py +0 -0
- {plain-0.1.2 → plain-0.2.1}/plain/internal/files/uploadhandler.py +0 -0
- {plain-0.1.2 → plain-0.2.1}/plain/internal/files/utils.py +0 -0
- {plain-0.1.2/plain/internal/legacy → plain-0.2.1/plain/internal/handlers}/__init__.py +0 -0
- {plain-0.1.2 → plain-0.2.1}/plain/internal/handlers/base.py +0 -0
- {plain-0.1.2 → plain-0.2.1}/plain/internal/handlers/exception.py +0 -0
- {plain-0.1.2 → plain-0.2.1}/plain/internal/handlers/wsgi.py +0 -0
- {plain-0.1.2/plain/internal/legacy/management/commands → plain-0.2.1/plain/internal/legacy}/__init__.py +0 -0
- {plain-0.1.2 → plain-0.2.1}/plain/internal/legacy/__main__.py +0 -0
- {plain-0.1.2 → plain-0.2.1}/plain/internal/legacy/management/__init__.py +0 -0
- {plain-0.1.2 → plain-0.2.1}/plain/internal/legacy/management/base.py +0 -0
- {plain-0.1.2 → plain-0.2.1}/plain/internal/legacy/management/color.py +0 -0
- {plain-0.1.2 → plain-0.2.1}/plain/internal/legacy/management/sql.py +0 -0
- {plain-0.1.2 → plain-0.2.1}/plain/internal/legacy/management/utils.py +0 -0
- {plain-0.1.2 → plain-0.2.1}/plain/json.py +0 -0
- {plain-0.1.2 → plain-0.2.1}/plain/logs/README.md +0 -0
- {plain-0.1.2 → plain-0.2.1}/plain/logs/__init__.py +0 -0
- {plain-0.1.2 → plain-0.2.1}/plain/logs/configure.py +0 -0
- {plain-0.1.2 → plain-0.2.1}/plain/logs/loggers.py +0 -0
- {plain-0.1.2 → plain-0.2.1}/plain/logs/utils.py +0 -0
- {plain-0.1.2 → plain-0.2.1}/plain/middleware/README.md +0 -0
- {plain-0.1.2 → plain-0.2.1}/plain/middleware/__init__.py +0 -0
- {plain-0.1.2 → plain-0.2.1}/plain/middleware/clickjacking.py +0 -0
- {plain-0.1.2 → plain-0.2.1}/plain/middleware/common.py +0 -0
- {plain-0.1.2 → plain-0.2.1}/plain/middleware/gzip.py +0 -0
- {plain-0.1.2 → plain-0.2.1}/plain/middleware/security.py +0 -0
- {plain-0.1.2 → plain-0.2.1}/plain/packages/README.md +0 -0
- {plain-0.1.2 → plain-0.2.1}/plain/packages/__init__.py +0 -0
- {plain-0.1.2 → plain-0.2.1}/plain/paginator.py +0 -0
- {plain-0.1.2 → plain-0.2.1}/plain/preflight/README.md +0 -0
- {plain-0.1.2 → plain-0.2.1}/plain/preflight/__init__.py +0 -0
- {plain-0.1.2 → plain-0.2.1}/plain/preflight/compatibility/__init__.py +0 -0
- {plain-0.1.2 → plain-0.2.1}/plain/preflight/compatibility/django_4_0.py +0 -0
- {plain-0.1.2 → plain-0.2.1}/plain/preflight/files.py +0 -0
- {plain-0.1.2 → plain-0.2.1}/plain/preflight/messages.py +0 -0
- {plain-0.1.2 → plain-0.2.1}/plain/preflight/registry.py +0 -0
- {plain-0.1.2 → plain-0.2.1}/plain/preflight/security/__init__.py +0 -0
- {plain-0.1.2 → plain-0.2.1}/plain/preflight/security/base.py +0 -0
- {plain-0.1.2 → plain-0.2.1}/plain/preflight/security/csrf.py +0 -0
- {plain-0.1.2 → plain-0.2.1}/plain/runtime/__init__.py +0 -0
- {plain-0.1.2 → plain-0.2.1}/plain/signals/README.md +0 -0
- {plain-0.1.2 → plain-0.2.1}/plain/signals/__init__.py +0 -0
- {plain-0.1.2 → plain-0.2.1}/plain/signals/dispatch/__init__.py +0 -0
- {plain-0.1.2 → plain-0.2.1}/plain/signals/dispatch/dispatcher.py +0 -0
- {plain-0.1.2 → plain-0.2.1}/plain/signals/dispatch/license.txt +0 -0
- {plain-0.1.2 → plain-0.2.1}/plain/signing.py +0 -0
- {plain-0.1.2 → plain-0.2.1}/plain/templates/README.md +0 -0
- {plain-0.1.2 → plain-0.2.1}/plain/templates/__init__.py +0 -0
- {plain-0.1.2 → plain-0.2.1}/plain/templates/core.py +0 -0
- {plain-0.1.2 → plain-0.2.1}/plain/templates/jinja/README.md +0 -0
- {plain-0.1.2 → plain-0.2.1}/plain/templates/jinja/__init__.py +0 -0
- {plain-0.1.2 → plain-0.2.1}/plain/templates/jinja/defaults.py +0 -0
- {plain-0.1.2 → plain-0.2.1}/plain/templates/jinja/extensions.py +0 -0
- {plain-0.1.2 → plain-0.2.1}/plain/templates/jinja/filters.py +0 -0
- {plain-0.1.2 → plain-0.2.1}/plain/test/README.md +0 -0
- {plain-0.1.2 → plain-0.2.1}/plain/urls/README.md +0 -0
- {plain-0.1.2 → plain-0.2.1}/plain/urls/__init__.py +0 -0
- {plain-0.1.2 → plain-0.2.1}/plain/urls/base.py +0 -0
- {plain-0.1.2 → plain-0.2.1}/plain/urls/conf.py +0 -0
- {plain-0.1.2 → plain-0.2.1}/plain/urls/converters.py +0 -0
- {plain-0.1.2 → plain-0.2.1}/plain/urls/exceptions.py +0 -0
- {plain-0.1.2 → plain-0.2.1}/plain/urls/resolvers.py +0 -0
- {plain-0.1.2 → plain-0.2.1}/plain/utils/README.md +0 -0
- {plain-0.1.2 → plain-0.2.1}/plain/utils/__init__.py +0 -0
- {plain-0.1.2 → plain-0.2.1}/plain/utils/_os.py +0 -0
- {plain-0.1.2 → plain-0.2.1}/plain/utils/cache.py +0 -0
- {plain-0.1.2 → plain-0.2.1}/plain/utils/connection.py +0 -0
- {plain-0.1.2 → plain-0.2.1}/plain/utils/crypto.py +0 -0
- {plain-0.1.2 → plain-0.2.1}/plain/utils/datastructures.py +0 -0
- {plain-0.1.2 → plain-0.2.1}/plain/utils/dateformat.py +0 -0
- {plain-0.1.2 → plain-0.2.1}/plain/utils/dateparse.py +0 -0
- {plain-0.1.2 → plain-0.2.1}/plain/utils/dates.py +0 -0
- {plain-0.1.2 → plain-0.2.1}/plain/utils/deconstruct.py +0 -0
- {plain-0.1.2 → plain-0.2.1}/plain/utils/decorators.py +0 -0
- {plain-0.1.2 → plain-0.2.1}/plain/utils/deprecation.py +0 -0
- {plain-0.1.2 → plain-0.2.1}/plain/utils/duration.py +0 -0
- {plain-0.1.2 → plain-0.2.1}/plain/utils/email.py +0 -0
- {plain-0.1.2 → plain-0.2.1}/plain/utils/encoding.py +0 -0
- {plain-0.1.2 → plain-0.2.1}/plain/utils/functional.py +0 -0
- {plain-0.1.2 → plain-0.2.1}/plain/utils/hashable.py +0 -0
- {plain-0.1.2 → plain-0.2.1}/plain/utils/html.py +0 -0
- {plain-0.1.2 → plain-0.2.1}/plain/utils/http.py +0 -0
- {plain-0.1.2 → plain-0.2.1}/plain/utils/inspect.py +0 -0
- {plain-0.1.2 → plain-0.2.1}/plain/utils/ipv6.py +0 -0
- {plain-0.1.2 → plain-0.2.1}/plain/utils/itercompat.py +0 -0
- {plain-0.1.2 → plain-0.2.1}/plain/utils/module_loading.py +0 -0
- {plain-0.1.2 → plain-0.2.1}/plain/utils/regex_helper.py +0 -0
- {plain-0.1.2 → plain-0.2.1}/plain/utils/safestring.py +0 -0
- {plain-0.1.2 → plain-0.2.1}/plain/utils/termcolors.py +0 -0
- {plain-0.1.2 → plain-0.2.1}/plain/utils/text.py +0 -0
- {plain-0.1.2 → plain-0.2.1}/plain/utils/timesince.py +0 -0
- {plain-0.1.2 → plain-0.2.1}/plain/utils/timezone.py +0 -0
- {plain-0.1.2 → plain-0.2.1}/plain/utils/tree.py +0 -0
- {plain-0.1.2 → plain-0.2.1}/plain/validators.py +0 -0
- {plain-0.1.2 → plain-0.2.1}/plain/views/README.md +0 -0
- {plain-0.1.2 → plain-0.2.1}/plain/views/__init__.py +0 -0
- {plain-0.1.2 → plain-0.2.1}/plain/views/csrf.py +0 -0
- {plain-0.1.2 → plain-0.2.1}/plain/views/exceptions.py +0 -0
- {plain-0.1.2 → plain-0.2.1}/plain/views/forms.py +0 -0
- {plain-0.1.2 → plain-0.2.1}/plain/views/objects.py +0 -0
- {plain-0.1.2 → plain-0.2.1}/plain/views/redirect.py +0 -0
- {plain-0.1.2 → plain-0.2.1}/plain/views/templates.py +0 -0
- {plain-0.1.2 → plain-0.2.1}/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,94 @@
|
|
1
|
+
# Assets
|
2
|
+
|
3
|
+
Serve static assets (CSS, JS, images, etc.) directly from your app.
|
4
|
+
|
5
|
+
|
6
|
+
## Usage
|
7
|
+
|
8
|
+
To serve assets, put them in `app/assets` or `app/{package}/assets`.
|
9
|
+
|
10
|
+
Then include the `plain.assets.urls` in your `urls.py`:
|
11
|
+
|
12
|
+
```python
|
13
|
+
# app/urls.py
|
14
|
+
from plain.urls import include, path
|
15
|
+
import plain.assets.urls
|
16
|
+
|
17
|
+
|
18
|
+
urlpatterns = [
|
19
|
+
path("assets/", include(plain.assets.urls)),
|
20
|
+
# ...
|
21
|
+
]
|
22
|
+
```
|
23
|
+
|
24
|
+
Now in your template you can use the `asset()` function to get the URL:
|
25
|
+
|
26
|
+
```html
|
27
|
+
<link rel="stylesheet" href="{{ asset('css/style.css') }}">
|
28
|
+
```
|
29
|
+
|
30
|
+
|
31
|
+
## Local development
|
32
|
+
|
33
|
+
When you're working with `settings.DEBUG = True`, the assets will be served directly from their original location. You don't need to run `plain compile` or configure anything else.
|
34
|
+
|
35
|
+
|
36
|
+
## Production deployment
|
37
|
+
|
38
|
+
In production, one of your deployment steps should be to compile the assets.
|
39
|
+
|
40
|
+
```bash
|
41
|
+
plain compile
|
42
|
+
```
|
43
|
+
|
44
|
+
By default, this generates "fingerprinted" and compressed versions of the assets, which are then served by your app. This means that a file like `main.css` will result in two new files, like `main.d0db67b.css` and `main.d0db67b.css.gz`.
|
45
|
+
|
46
|
+
The purpose of fingerprinting the assets is to allow the browser to cache them indefinitely. When the content of the file changes, the fingerprint will change, and the browser will use the newer file. This cuts down on the number of requests that your app has to handle related to assets.
|
47
|
+
|
48
|
+
|
49
|
+
## FAQs
|
50
|
+
|
51
|
+
### How do you reference assets in Python code?
|
52
|
+
|
53
|
+
```python
|
54
|
+
from plain.assets.urls import get_asset_url
|
55
|
+
|
56
|
+
url = get_asset_url("css/style.css")
|
57
|
+
```
|
58
|
+
|
59
|
+
### What if I need the files in a different location?
|
60
|
+
|
61
|
+
The generated/copied files are stored in `{repo}/.plain/assets/compiled`. If you need them to be somewhere else, try simply moving them after compilation.
|
62
|
+
|
63
|
+
```bash
|
64
|
+
plain compile
|
65
|
+
mv .plain/assets/compiled /path/to/your/static
|
66
|
+
```
|
67
|
+
|
68
|
+
### How do I upload the assets to a CDN?
|
69
|
+
|
70
|
+
The steps for this will vary, but the general idea is to compile them, and then upload the compiled assets.
|
71
|
+
|
72
|
+
```bash
|
73
|
+
plain compile
|
74
|
+
./example-upload-to-cdn-script
|
75
|
+
```
|
76
|
+
|
77
|
+
Use the `ASSETS_BASE_URL` setting to tell the `{{ asset() }}` template function where to point.
|
78
|
+
|
79
|
+
```python
|
80
|
+
# app/settings.py
|
81
|
+
ASSETS_BASE_URL = "https://cdn.example.com/"
|
82
|
+
```
|
83
|
+
|
84
|
+
|
85
|
+
### Why aren't the originals copied to the compiled directory?
|
86
|
+
|
87
|
+
The default behavior is to fingerprint assets, which is an exact copy of the original file but with a different filename. The originals aren't copied over because you should generally always use this fingerprinted path (that automatically uses longer-lived caching).
|
88
|
+
|
89
|
+
If you need the originals for any reason, you can use `plain compile --keep-original`, though this will typically be combined with `--no-fingerprint` otherwise the fingerprinted files will still get priority in `{{ asset() }}` template calls.
|
90
|
+
|
91
|
+
|
92
|
+
### What about source maps or imported css files?
|
93
|
+
|
94
|
+
TODO
|
@@ -0,0 +1,121 @@
|
|
1
|
+
import gzip
|
2
|
+
import hashlib
|
3
|
+
import os
|
4
|
+
import shutil
|
5
|
+
|
6
|
+
from plain.runtime import settings
|
7
|
+
|
8
|
+
from .finders import find_assets
|
9
|
+
from .fingerprints import AssetsFingerprintsManifest
|
10
|
+
|
11
|
+
FINGERPRINT_LENGTH = 7
|
12
|
+
|
13
|
+
SKIP_COMPRESS_EXTENSIONS = (
|
14
|
+
# Images
|
15
|
+
"jpg",
|
16
|
+
"jpeg",
|
17
|
+
"png",
|
18
|
+
"gif",
|
19
|
+
"webp",
|
20
|
+
# Compressed files
|
21
|
+
"zip",
|
22
|
+
"gz",
|
23
|
+
"tgz",
|
24
|
+
"bz2",
|
25
|
+
"tbz",
|
26
|
+
"xz",
|
27
|
+
"br",
|
28
|
+
# Fonts
|
29
|
+
"woff",
|
30
|
+
"woff2",
|
31
|
+
# Video
|
32
|
+
"3gp",
|
33
|
+
"3gpp",
|
34
|
+
"asf",
|
35
|
+
"avi",
|
36
|
+
"m4v",
|
37
|
+
"mov",
|
38
|
+
"mp4",
|
39
|
+
"mpeg",
|
40
|
+
"mpg",
|
41
|
+
"webm",
|
42
|
+
"wmv",
|
43
|
+
)
|
44
|
+
|
45
|
+
|
46
|
+
def get_compiled_path():
|
47
|
+
"""
|
48
|
+
Get the path at runtime to the compiled assets directory.
|
49
|
+
There's no reason currently for this to be a user-facing setting.
|
50
|
+
"""
|
51
|
+
return settings.PLAIN_TEMP_PATH / "assets" / "compiled"
|
52
|
+
|
53
|
+
|
54
|
+
def compile_assets(*, target_dir, keep_original, fingerprint, compress):
|
55
|
+
manifest = AssetsFingerprintsManifest()
|
56
|
+
|
57
|
+
for url_path, asset in find_assets().items():
|
58
|
+
resolved_path, compiled_paths = compile_asset(
|
59
|
+
asset=asset,
|
60
|
+
target_dir=target_dir,
|
61
|
+
keep_original=keep_original,
|
62
|
+
fingerprint=fingerprint,
|
63
|
+
compress=compress,
|
64
|
+
)
|
65
|
+
if resolved_path != url_path:
|
66
|
+
manifest[url_path] = resolved_path
|
67
|
+
|
68
|
+
yield url_path, resolved_path, compiled_paths
|
69
|
+
|
70
|
+
if manifest:
|
71
|
+
manifest.save()
|
72
|
+
|
73
|
+
|
74
|
+
def compile_asset(*, asset, target_dir, keep_original, fingerprint, compress):
|
75
|
+
"""
|
76
|
+
Compile an asset to multiple output paths.
|
77
|
+
"""
|
78
|
+
compiled_paths = []
|
79
|
+
|
80
|
+
# The expected destination for the original asset
|
81
|
+
target_path = os.path.join(target_dir, asset.url_path)
|
82
|
+
|
83
|
+
# Keep track of where the final, resolved asset ends up
|
84
|
+
resolved_url_path = asset.url_path
|
85
|
+
|
86
|
+
# Make sure all the expected directories exist
|
87
|
+
os.makedirs(os.path.dirname(target_path), exist_ok=True)
|
88
|
+
|
89
|
+
base, extension = os.path.splitext(asset.url_path)
|
90
|
+
|
91
|
+
# First, copy the original asset over
|
92
|
+
if keep_original:
|
93
|
+
shutil.copy(asset.absolute_path, target_path)
|
94
|
+
compiled_paths.append(target_path)
|
95
|
+
|
96
|
+
if fingerprint:
|
97
|
+
# Fingerprint it with an md5 hash
|
98
|
+
# (maybe need a setting with fnmatch patterns for files to NOT fingerprint?
|
99
|
+
# that would allow pre-fingerprinted files to be used as-is, and keep source maps etc in tact)
|
100
|
+
with open(asset.absolute_path, "rb") as f:
|
101
|
+
content = f.read()
|
102
|
+
fingerprint_hash = hashlib.md5(content, usedforsecurity=False).hexdigest()[
|
103
|
+
:FINGERPRINT_LENGTH
|
104
|
+
]
|
105
|
+
|
106
|
+
fingerprinted_basename = f"{base}.{fingerprint_hash}{extension}"
|
107
|
+
fingerprinted_path = os.path.join(target_dir, fingerprinted_basename)
|
108
|
+
shutil.copy(asset.absolute_path, fingerprinted_path)
|
109
|
+
compiled_paths.append(fingerprinted_path)
|
110
|
+
|
111
|
+
resolved_url_path = os.path.relpath(fingerprinted_path, target_dir)
|
112
|
+
|
113
|
+
if compress and extension not in SKIP_COMPRESS_EXTENSIONS:
|
114
|
+
for path in compiled_paths.copy():
|
115
|
+
gzip_path = f"{path}.gz"
|
116
|
+
with gzip.GzipFile(gzip_path, "wb") as f:
|
117
|
+
with open(path, "rb") as f2:
|
118
|
+
f.write(f2.read())
|
119
|
+
compiled_paths.append(gzip_path)
|
120
|
+
|
121
|
+
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
|