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.
Files changed (175) hide show
  1. {plain-0.1.2 → plain-0.2.1}/LICENSE +0 -24
  2. {plain-0.1.2 → plain-0.2.1}/PKG-INFO +1 -1
  3. plain-0.2.1/plain/assets/README.md +94 -0
  4. plain-0.2.1/plain/assets/compile.py +121 -0
  5. plain-0.2.1/plain/assets/finders.py +41 -0
  6. plain-0.2.1/plain/assets/fingerprints.py +38 -0
  7. plain-0.2.1/plain/assets/urls.py +31 -0
  8. plain-0.2.1/plain/assets/views.py +263 -0
  9. {plain-0.1.2 → plain-0.2.1}/plain/cli/cli.py +67 -5
  10. {plain-0.1.2 → plain-0.2.1}/plain/packages/config.py +5 -5
  11. {plain-0.1.2 → plain-0.2.1}/plain/packages/registry.py +1 -7
  12. {plain-0.1.2 → plain-0.2.1}/plain/preflight/urls.py +0 -10
  13. {plain-0.1.2 → plain-0.2.1}/plain/runtime/README.md +0 -1
  14. {plain-0.1.2 → plain-0.2.1}/plain/runtime/global_settings.py +5 -16
  15. {plain-0.1.2 → plain-0.2.1}/plain/runtime/user_settings.py +0 -49
  16. {plain-0.1.2 → plain-0.2.1}/plain/templates/jinja/globals.py +1 -1
  17. plain-0.2.1/plain/test/__init__.py +8 -0
  18. {plain-0.1.2 → plain-0.2.1}/plain/test/client.py +36 -16
  19. {plain-0.1.2 → plain-0.2.1}/plain/views/base.py +5 -3
  20. {plain-0.1.2 → plain-0.2.1}/plain/views/errors.py +7 -0
  21. {plain-0.1.2 → plain-0.2.1}/pyproject.toml +1 -1
  22. plain-0.1.2/plain/assets/README.md +0 -56
  23. plain-0.1.2/plain/assets/__init__.py +0 -6
  24. plain-0.1.2/plain/assets/finders.py +0 -233
  25. plain-0.1.2/plain/assets/preflight.py +0 -14
  26. plain-0.1.2/plain/assets/storage.py +0 -916
  27. plain-0.1.2/plain/assets/utils.py +0 -52
  28. plain-0.1.2/plain/assets/whitenoise/__init__.py +0 -5
  29. plain-0.1.2/plain/assets/whitenoise/base.py +0 -259
  30. plain-0.1.2/plain/assets/whitenoise/compress.py +0 -189
  31. plain-0.1.2/plain/assets/whitenoise/media_types.py +0 -137
  32. plain-0.1.2/plain/assets/whitenoise/middleware.py +0 -197
  33. plain-0.1.2/plain/assets/whitenoise/responders.py +0 -286
  34. plain-0.1.2/plain/assets/whitenoise/storage.py +0 -178
  35. plain-0.1.2/plain/assets/whitenoise/string_utils.py +0 -13
  36. plain-0.1.2/plain/internal/legacy/management/commands/collectstatic.py +0 -297
  37. plain-0.1.2/plain/test/__init__.py +0 -16
  38. plain-0.1.2/plain/test/utils.py +0 -255
  39. {plain-0.1.2 → plain-0.2.1}/README.md +0 -0
  40. {plain-0.1.2 → plain-0.2.1}/plain/README.md +0 -0
  41. {plain-0.1.2 → plain-0.2.1}/plain/__main__.py +0 -0
  42. {plain-0.1.2/plain/internal → plain-0.2.1/plain/assets}/__init__.py +0 -0
  43. {plain-0.1.2 → plain-0.2.1}/plain/cli/README.md +0 -0
  44. {plain-0.1.2 → plain-0.2.1}/plain/cli/__init__.py +0 -0
  45. {plain-0.1.2 → plain-0.2.1}/plain/cli/formatting.py +0 -0
  46. {plain-0.1.2 → plain-0.2.1}/plain/cli/packages.py +0 -0
  47. {plain-0.1.2 → plain-0.2.1}/plain/cli/print.py +0 -0
  48. {plain-0.1.2 → plain-0.2.1}/plain/cli/startup.py +0 -0
  49. {plain-0.1.2 → plain-0.2.1}/plain/csrf/README.md +0 -0
  50. {plain-0.1.2 → plain-0.2.1}/plain/csrf/middleware.py +0 -0
  51. {plain-0.1.2 → plain-0.2.1}/plain/csrf/views.py +0 -0
  52. {plain-0.1.2 → plain-0.2.1}/plain/debug.py +0 -0
  53. {plain-0.1.2 → plain-0.2.1}/plain/exceptions.py +0 -0
  54. {plain-0.1.2 → plain-0.2.1}/plain/forms/README.md +0 -0
  55. {plain-0.1.2 → plain-0.2.1}/plain/forms/__init__.py +0 -0
  56. {plain-0.1.2 → plain-0.2.1}/plain/forms/boundfield.py +0 -0
  57. {plain-0.1.2 → plain-0.2.1}/plain/forms/exceptions.py +0 -0
  58. {plain-0.1.2 → plain-0.2.1}/plain/forms/fields.py +0 -0
  59. {plain-0.1.2 → plain-0.2.1}/plain/forms/forms.py +0 -0
  60. {plain-0.1.2 → plain-0.2.1}/plain/http/README.md +0 -0
  61. {plain-0.1.2 → plain-0.2.1}/plain/http/__init__.py +0 -0
  62. {plain-0.1.2 → plain-0.2.1}/plain/http/cookie.py +0 -0
  63. {plain-0.1.2 → plain-0.2.1}/plain/http/multipartparser.py +0 -0
  64. {plain-0.1.2 → plain-0.2.1}/plain/http/request.py +0 -0
  65. {plain-0.1.2 → plain-0.2.1}/plain/http/response.py +0 -0
  66. {plain-0.1.2/plain/internal/handlers → plain-0.2.1/plain/internal}/__init__.py +0 -0
  67. {plain-0.1.2 → plain-0.2.1}/plain/internal/files/README.md +0 -0
  68. {plain-0.1.2 → plain-0.2.1}/plain/internal/files/__init__.py +0 -0
  69. {plain-0.1.2 → plain-0.2.1}/plain/internal/files/base.py +0 -0
  70. {plain-0.1.2 → plain-0.2.1}/plain/internal/files/locks.py +0 -0
  71. {plain-0.1.2 → plain-0.2.1}/plain/internal/files/move.py +0 -0
  72. {plain-0.1.2 → plain-0.2.1}/plain/internal/files/temp.py +0 -0
  73. {plain-0.1.2 → plain-0.2.1}/plain/internal/files/uploadedfile.py +0 -0
  74. {plain-0.1.2 → plain-0.2.1}/plain/internal/files/uploadhandler.py +0 -0
  75. {plain-0.1.2 → plain-0.2.1}/plain/internal/files/utils.py +0 -0
  76. {plain-0.1.2/plain/internal/legacy → plain-0.2.1/plain/internal/handlers}/__init__.py +0 -0
  77. {plain-0.1.2 → plain-0.2.1}/plain/internal/handlers/base.py +0 -0
  78. {plain-0.1.2 → plain-0.2.1}/plain/internal/handlers/exception.py +0 -0
  79. {plain-0.1.2 → plain-0.2.1}/plain/internal/handlers/wsgi.py +0 -0
  80. {plain-0.1.2/plain/internal/legacy/management/commands → plain-0.2.1/plain/internal/legacy}/__init__.py +0 -0
  81. {plain-0.1.2 → plain-0.2.1}/plain/internal/legacy/__main__.py +0 -0
  82. {plain-0.1.2 → plain-0.2.1}/plain/internal/legacy/management/__init__.py +0 -0
  83. {plain-0.1.2 → plain-0.2.1}/plain/internal/legacy/management/base.py +0 -0
  84. {plain-0.1.2 → plain-0.2.1}/plain/internal/legacy/management/color.py +0 -0
  85. {plain-0.1.2 → plain-0.2.1}/plain/internal/legacy/management/sql.py +0 -0
  86. {plain-0.1.2 → plain-0.2.1}/plain/internal/legacy/management/utils.py +0 -0
  87. {plain-0.1.2 → plain-0.2.1}/plain/json.py +0 -0
  88. {plain-0.1.2 → plain-0.2.1}/plain/logs/README.md +0 -0
  89. {plain-0.1.2 → plain-0.2.1}/plain/logs/__init__.py +0 -0
  90. {plain-0.1.2 → plain-0.2.1}/plain/logs/configure.py +0 -0
  91. {plain-0.1.2 → plain-0.2.1}/plain/logs/loggers.py +0 -0
  92. {plain-0.1.2 → plain-0.2.1}/plain/logs/utils.py +0 -0
  93. {plain-0.1.2 → plain-0.2.1}/plain/middleware/README.md +0 -0
  94. {plain-0.1.2 → plain-0.2.1}/plain/middleware/__init__.py +0 -0
  95. {plain-0.1.2 → plain-0.2.1}/plain/middleware/clickjacking.py +0 -0
  96. {plain-0.1.2 → plain-0.2.1}/plain/middleware/common.py +0 -0
  97. {plain-0.1.2 → plain-0.2.1}/plain/middleware/gzip.py +0 -0
  98. {plain-0.1.2 → plain-0.2.1}/plain/middleware/security.py +0 -0
  99. {plain-0.1.2 → plain-0.2.1}/plain/packages/README.md +0 -0
  100. {plain-0.1.2 → plain-0.2.1}/plain/packages/__init__.py +0 -0
  101. {plain-0.1.2 → plain-0.2.1}/plain/paginator.py +0 -0
  102. {plain-0.1.2 → plain-0.2.1}/plain/preflight/README.md +0 -0
  103. {plain-0.1.2 → plain-0.2.1}/plain/preflight/__init__.py +0 -0
  104. {plain-0.1.2 → plain-0.2.1}/plain/preflight/compatibility/__init__.py +0 -0
  105. {plain-0.1.2 → plain-0.2.1}/plain/preflight/compatibility/django_4_0.py +0 -0
  106. {plain-0.1.2 → plain-0.2.1}/plain/preflight/files.py +0 -0
  107. {plain-0.1.2 → plain-0.2.1}/plain/preflight/messages.py +0 -0
  108. {plain-0.1.2 → plain-0.2.1}/plain/preflight/registry.py +0 -0
  109. {plain-0.1.2 → plain-0.2.1}/plain/preflight/security/__init__.py +0 -0
  110. {plain-0.1.2 → plain-0.2.1}/plain/preflight/security/base.py +0 -0
  111. {plain-0.1.2 → plain-0.2.1}/plain/preflight/security/csrf.py +0 -0
  112. {plain-0.1.2 → plain-0.2.1}/plain/runtime/__init__.py +0 -0
  113. {plain-0.1.2 → plain-0.2.1}/plain/signals/README.md +0 -0
  114. {plain-0.1.2 → plain-0.2.1}/plain/signals/__init__.py +0 -0
  115. {plain-0.1.2 → plain-0.2.1}/plain/signals/dispatch/__init__.py +0 -0
  116. {plain-0.1.2 → plain-0.2.1}/plain/signals/dispatch/dispatcher.py +0 -0
  117. {plain-0.1.2 → plain-0.2.1}/plain/signals/dispatch/license.txt +0 -0
  118. {plain-0.1.2 → plain-0.2.1}/plain/signing.py +0 -0
  119. {plain-0.1.2 → plain-0.2.1}/plain/templates/README.md +0 -0
  120. {plain-0.1.2 → plain-0.2.1}/plain/templates/__init__.py +0 -0
  121. {plain-0.1.2 → plain-0.2.1}/plain/templates/core.py +0 -0
  122. {plain-0.1.2 → plain-0.2.1}/plain/templates/jinja/README.md +0 -0
  123. {plain-0.1.2 → plain-0.2.1}/plain/templates/jinja/__init__.py +0 -0
  124. {plain-0.1.2 → plain-0.2.1}/plain/templates/jinja/defaults.py +0 -0
  125. {plain-0.1.2 → plain-0.2.1}/plain/templates/jinja/extensions.py +0 -0
  126. {plain-0.1.2 → plain-0.2.1}/plain/templates/jinja/filters.py +0 -0
  127. {plain-0.1.2 → plain-0.2.1}/plain/test/README.md +0 -0
  128. {plain-0.1.2 → plain-0.2.1}/plain/urls/README.md +0 -0
  129. {plain-0.1.2 → plain-0.2.1}/plain/urls/__init__.py +0 -0
  130. {plain-0.1.2 → plain-0.2.1}/plain/urls/base.py +0 -0
  131. {plain-0.1.2 → plain-0.2.1}/plain/urls/conf.py +0 -0
  132. {plain-0.1.2 → plain-0.2.1}/plain/urls/converters.py +0 -0
  133. {plain-0.1.2 → plain-0.2.1}/plain/urls/exceptions.py +0 -0
  134. {plain-0.1.2 → plain-0.2.1}/plain/urls/resolvers.py +0 -0
  135. {plain-0.1.2 → plain-0.2.1}/plain/utils/README.md +0 -0
  136. {plain-0.1.2 → plain-0.2.1}/plain/utils/__init__.py +0 -0
  137. {plain-0.1.2 → plain-0.2.1}/plain/utils/_os.py +0 -0
  138. {plain-0.1.2 → plain-0.2.1}/plain/utils/cache.py +0 -0
  139. {plain-0.1.2 → plain-0.2.1}/plain/utils/connection.py +0 -0
  140. {plain-0.1.2 → plain-0.2.1}/plain/utils/crypto.py +0 -0
  141. {plain-0.1.2 → plain-0.2.1}/plain/utils/datastructures.py +0 -0
  142. {plain-0.1.2 → plain-0.2.1}/plain/utils/dateformat.py +0 -0
  143. {plain-0.1.2 → plain-0.2.1}/plain/utils/dateparse.py +0 -0
  144. {plain-0.1.2 → plain-0.2.1}/plain/utils/dates.py +0 -0
  145. {plain-0.1.2 → plain-0.2.1}/plain/utils/deconstruct.py +0 -0
  146. {plain-0.1.2 → plain-0.2.1}/plain/utils/decorators.py +0 -0
  147. {plain-0.1.2 → plain-0.2.1}/plain/utils/deprecation.py +0 -0
  148. {plain-0.1.2 → plain-0.2.1}/plain/utils/duration.py +0 -0
  149. {plain-0.1.2 → plain-0.2.1}/plain/utils/email.py +0 -0
  150. {plain-0.1.2 → plain-0.2.1}/plain/utils/encoding.py +0 -0
  151. {plain-0.1.2 → plain-0.2.1}/plain/utils/functional.py +0 -0
  152. {plain-0.1.2 → plain-0.2.1}/plain/utils/hashable.py +0 -0
  153. {plain-0.1.2 → plain-0.2.1}/plain/utils/html.py +0 -0
  154. {plain-0.1.2 → plain-0.2.1}/plain/utils/http.py +0 -0
  155. {plain-0.1.2 → plain-0.2.1}/plain/utils/inspect.py +0 -0
  156. {plain-0.1.2 → plain-0.2.1}/plain/utils/ipv6.py +0 -0
  157. {plain-0.1.2 → plain-0.2.1}/plain/utils/itercompat.py +0 -0
  158. {plain-0.1.2 → plain-0.2.1}/plain/utils/module_loading.py +0 -0
  159. {plain-0.1.2 → plain-0.2.1}/plain/utils/regex_helper.py +0 -0
  160. {plain-0.1.2 → plain-0.2.1}/plain/utils/safestring.py +0 -0
  161. {plain-0.1.2 → plain-0.2.1}/plain/utils/termcolors.py +0 -0
  162. {plain-0.1.2 → plain-0.2.1}/plain/utils/text.py +0 -0
  163. {plain-0.1.2 → plain-0.2.1}/plain/utils/timesince.py +0 -0
  164. {plain-0.1.2 → plain-0.2.1}/plain/utils/timezone.py +0 -0
  165. {plain-0.1.2 → plain-0.2.1}/plain/utils/tree.py +0 -0
  166. {plain-0.1.2 → plain-0.2.1}/plain/validators.py +0 -0
  167. {plain-0.1.2 → plain-0.2.1}/plain/views/README.md +0 -0
  168. {plain-0.1.2 → plain-0.2.1}/plain/views/__init__.py +0 -0
  169. {plain-0.1.2 → plain-0.2.1}/plain/views/csrf.py +0 -0
  170. {plain-0.1.2 → plain-0.2.1}/plain/views/exceptions.py +0 -0
  171. {plain-0.1.2 → plain-0.2.1}/plain/views/forms.py +0 -0
  172. {plain-0.1.2 → plain-0.2.1}/plain/views/objects.py +0 -0
  173. {plain-0.1.2 → plain-0.2.1}/plain/views/redirect.py +0 -0
  174. {plain-0.1.2 → plain-0.2.1}/plain/views/templates.py +0 -0
  175. {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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: plain
3
- Version: 0.1.2
3
+ Version: 0.2.1
4
4
  Summary: A web framework for building products with Python.
5
5
  Author: Dave Gaeddert
6
6
  Author-email: dave.gaeddert@dropseed.dev
@@ -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