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