plain 0.1.1__py3-none-any.whl → 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. plain/assets/README.md +18 -37
  2. plain/assets/__init__.py +0 -6
  3. plain/assets/compile.py +111 -0
  4. plain/assets/finders.py +26 -218
  5. plain/assets/fingerprints.py +38 -0
  6. plain/assets/urls.py +31 -0
  7. plain/assets/views.py +263 -0
  8. plain/cli/cli.py +68 -5
  9. plain/csrf/README.md +12 -0
  10. plain/packages/config.py +5 -5
  11. plain/packages/registry.py +1 -7
  12. plain/preflight/urls.py +0 -10
  13. plain/runtime/README.md +0 -1
  14. plain/runtime/__init__.py +1 -1
  15. plain/runtime/global_settings.py +7 -14
  16. plain/runtime/user_settings.py +0 -49
  17. plain/templates/jinja/globals.py +1 -1
  18. plain/test/__init__.py +0 -8
  19. plain/test/client.py +36 -16
  20. plain/views/base.py +5 -3
  21. plain/views/errors.py +7 -0
  22. {plain-0.1.1.dist-info → plain-0.2.0.dist-info}/LICENSE +0 -24
  23. {plain-0.1.1.dist-info → plain-0.2.0.dist-info}/METADATA +1 -1
  24. {plain-0.1.1.dist-info → plain-0.2.0.dist-info}/RECORD +26 -36
  25. plain/assets/preflight.py +0 -14
  26. plain/assets/storage.py +0 -916
  27. plain/assets/utils.py +0 -52
  28. plain/assets/whitenoise/__init__.py +0 -5
  29. plain/assets/whitenoise/base.py +0 -259
  30. plain/assets/whitenoise/compress.py +0 -189
  31. plain/assets/whitenoise/media_types.py +0 -137
  32. plain/assets/whitenoise/middleware.py +0 -197
  33. plain/assets/whitenoise/responders.py +0 -286
  34. plain/assets/whitenoise/storage.py +0 -178
  35. plain/assets/whitenoise/string_utils.py +0 -13
  36. plain/internal/legacy/management/commands/__init__.py +0 -0
  37. plain/internal/legacy/management/commands/collectstatic.py +0 -297
  38. plain/test/utils.py +0 -255
  39. {plain-0.1.1.dist-info → plain-0.2.0.dist-info}/WHEEL +0 -0
  40. {plain-0.1.1.dist-info → plain-0.2.0.dist-info}/entry_points.txt +0 -0
plain/assets/README.md CHANGED
@@ -1,13 +1,26 @@
1
1
  # Assets
2
2
 
3
- Serve static assets (CSS, JS, images, etc.) for your app.
3
+ Serve static assets (CSS, JS, images, etc.) directly from your app.
4
4
 
5
- The default behavior is for Plain to serve assets directly via a middleware.
6
- This is based on [whitenoise](http://whitenoise.evans.io/en/stable/).
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/).
7
6
 
8
7
  ## Usage
9
8
 
10
- Generally speaking, the simplest way to include assests in your app is to put them either in `app/assets` or `app/<package>/assets`.
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
+ ```
11
24
 
12
25
  Then in your template you can use the `asset()` function to get the URL.
13
26
 
@@ -18,39 +31,7 @@ Then in your template you can use the `asset()` function to get the URL.
18
31
  If you ever need to reference an asset directly in Python code, you can use the `get_asset_url()` function.
19
32
 
20
33
  ```python
21
- from plain.assets import get_asset_url
34
+ from plain.assets.urls import get_asset_url
22
35
 
23
36
  print(get_asset_url("css/style.css"))
24
37
  ```
25
-
26
- ## Settings
27
-
28
- These are the default settings related to assets handling.
29
-
30
- ```python
31
- # app/settings.py
32
- MIDDLEWARE = [
33
- "plain.middleware.security.SecurityMiddleware",
34
- "plain.assets.whitenoise.middleware.WhiteNoiseMiddleware", # <--
35
- "plain.middleware.common.CommonMiddleware",
36
- "plain.csrf.middleware.CsrfViewMiddleware",
37
- "plain.middleware.clickjacking.XFrameOptionsMiddleware",
38
- ]
39
-
40
- ASSETS_BACKEND = "plain.assets.whitenoise.storage.CompressedManifestStaticFilesStorage"
41
-
42
- # List of finder classes that know how to find assets files in
43
- # various locations.
44
- ASSETS_FINDERS = [
45
- "plain.assets.finders.FileSystemFinder",
46
- "plain.assets.finders.PackageDirectoriesFinder",
47
- ]
48
-
49
- # Absolute path to the directory assets files should be collected to.
50
- # Example: "/var/www/example.com/assets/"
51
- ASSETS_ROOT = PLAIN_TEMP_PATH / "assets_collected"
52
-
53
- # URL that handles the assets files served from ASSETS_ROOT.
54
- # Example: "http://example.com/assets/", "http://assets.example.com/"
55
- ASSETS_URL = "/assets/"
56
- ```
plain/assets/__init__.py CHANGED
@@ -1,6 +0,0 @@
1
- from . import preflight # noqa
2
- from .storage import assets_storage
3
-
4
-
5
- def get_asset_url(path):
6
- return assets_storage.url(path)
@@ -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
plain/assets/finders.py CHANGED
@@ -1,233 +1,41 @@
1
- import functools
2
1
  import os
3
2
 
4
- from plain.assets import utils
5
- from plain.assets.storage import FileSystemStorage
6
- from plain.exceptions import ImproperlyConfigured
7
3
  from plain.packages import packages
8
4
  from plain.runtime import APP_PATH
9
- from plain.utils._os import safe_join
10
- from plain.utils.module_loading import import_string
11
-
12
- # To keep track on which directories the finder has searched the static files.
13
- searched_locations = []
14
-
15
5
 
16
6
  APP_ASSETS_DIR = APP_PATH / "assets"
17
7
 
18
-
19
- class BaseFinder:
20
- """
21
- A base file finder to be used for custom assets finder classes.
22
- """
23
-
24
- def check(self, **kwargs):
25
- raise NotImplementedError(
26
- "subclasses may provide a check() method to verify the finder is "
27
- "configured correctly."
28
- )
29
-
30
- def find(self, path, all=False):
31
- """
32
- Given a relative file path, find an absolute file path.
33
-
34
- If the ``all`` parameter is False (default) return only the first found
35
- file path; if True, return a list of all found files paths.
36
- """
37
- raise NotImplementedError(
38
- "subclasses of BaseFinder must provide a find() method"
39
- )
40
-
41
- def list(self, ignore_patterns):
42
- """
43
- Given an optional list of paths to ignore, return a two item iterable
44
- consisting of the relative path and storage instance.
45
- """
46
- raise NotImplementedError(
47
- "subclasses of BaseFinder must provide a list() method"
48
- )
49
-
50
-
51
- class FileSystemFinder(BaseFinder):
52
- """
53
- A static files finder that looks in "static"
54
- """
55
-
56
- def __init__(self, package_names=None, *args, **kwargs):
57
- # List of locations with static files
58
- self.locations = []
59
- # Maps dir paths to an appropriate storage instance
60
- self.storages = {}
61
-
62
- root = APP_ASSETS_DIR
63
-
64
- if isinstance(root, list | tuple):
65
- prefix, root = root
66
- else:
67
- prefix = ""
68
- if (prefix, root) not in self.locations:
69
- self.locations.append((prefix, root))
70
- for prefix, root in self.locations:
71
- filesystem_storage = FileSystemStorage(location=root)
72
- filesystem_storage.prefix = prefix
73
- self.storages[root] = filesystem_storage
74
- super().__init__(*args, **kwargs)
75
-
76
- # def check(self, **kwargs):
77
- # errors = []
78
- # if settings.ASSETS_ROOT and os.path.abspath(
79
- # settings.ASSETS_ROOT
80
- # ) == os.path.abspath(self.path):
81
- # errors.append(
82
- # Error(
83
- # "The STATICFILES_DIR setting should not contain the "
84
- # "ASSETS_ROOT setting.",
85
- # id="assets.E002",
86
- # )
87
- # )
88
- # return errors
89
-
90
- def find(self, path, all=False):
91
- matches = []
92
- for prefix, root in self.locations:
93
- if root not in searched_locations:
94
- searched_locations.append(root)
95
- matched_path = self.find_location(root, path, prefix)
96
- if matched_path:
97
- if not all:
98
- return matched_path
99
- matches.append(matched_path)
100
- return matches
101
-
102
- def find_location(self, root, path, prefix=None):
103
- """
104
- Find a requested static file in a location and return the found
105
- absolute path (or ``None`` if no match).
106
- """
107
- if prefix:
108
- prefix = f"{prefix}{os.sep}"
109
- if not path.startswith(prefix):
110
- return None
111
- path = path.removeprefix(prefix)
112
- path = safe_join(root, path)
113
- if os.path.exists(path):
114
- return path
115
-
116
- def list(self, ignore_patterns):
117
- """
118
- List all files in all locations.
119
- """
120
- for prefix, root in self.locations:
121
- # Skip nonexistent directories.
122
- if os.path.isdir(root):
123
- storage = self.storages[root]
124
- for path in utils.get_files(storage, ignore_patterns):
125
- yield path, storage
126
-
127
-
128
- class PackageDirectoriesFinder(BaseFinder):
129
- """
130
- A static files finder that looks in the directory of each app as
131
- specified in the source_dir attribute.
132
- """
133
-
134
- storage_class = FileSystemStorage
135
- source_dir = "assets"
136
-
137
- def __init__(self, package_names=None, *args, **kwargs):
138
- # The list of packages that are handled
139
- self.packages = []
140
- # Mapping of app names to storage instances
141
- self.storages = {}
142
- package_configs = packages.get_package_configs()
143
- if package_names:
144
- package_names = set(package_names)
145
- package_configs = [ac for ac in package_configs if ac.name in package_names]
146
- for package_config in package_configs:
147
- app_storage = self.storage_class(
148
- os.path.join(package_config.path, self.source_dir)
149
- )
150
- if os.path.isdir(app_storage.location):
151
- self.storages[package_config.name] = app_storage
152
- if package_config.name not in self.packages:
153
- self.packages.append(package_config.name)
154
- super().__init__(*args, **kwargs)
155
-
156
- def list(self, ignore_patterns):
157
- """
158
- List all files in all app storages.
159
- """
160
- for storage in self.storages.values():
161
- if storage.exists(""): # check if storage location exists
162
- for path in utils.get_files(storage, ignore_patterns):
163
- yield path, storage
164
-
165
- def find(self, path, all=False):
166
- """
167
- Look for files in the app directories.
168
- """
169
- matches = []
170
- for app in self.packages:
171
- app_location = self.storages[app].location
172
- if app_location not in searched_locations:
173
- searched_locations.append(app_location)
174
- match = self.find_in_app(app, path)
175
- if match:
176
- if not all:
177
- return match
178
- matches.append(match)
179
- return matches
180
-
181
- def find_in_app(self, app, path):
182
- """
183
- Find a requested static file in an app's static locations.
184
- """
185
- storage = self.storages.get(app)
186
- # Only try to find a file if the source dir actually exists.
187
- if storage and storage.exists(path):
188
- matched_path = storage.path(path)
189
- if matched_path:
190
- return matched_path
8
+ SKIP_ASSETS = (".DS_Store", ".gitignore")
191
9
 
192
10
 
193
- def find(path, all=False):
194
- """
195
- Find a static file with the given path using all enabled finders.
11
+ def find_assets():
12
+ assets_map = {}
196
13
 
197
- If ``all`` is ``False`` (default), return the first matching
198
- absolute path (or ``None`` if no match). Otherwise return a list.
199
- """
200
- searched_locations[:] = []
201
- matches = []
202
- for finder in get_finders():
203
- result = finder.find(path, all=all)
204
- if not all and result:
205
- return result
206
- if not isinstance(result, list | tuple):
207
- result = [result]
208
- matches.extend(result)
209
- if matches:
210
- return matches
211
- # No match.
212
- return [] if all else None
14
+ class Asset:
15
+ def __init__(self, *, url_path, absolute_path):
16
+ self.url_path = url_path
17
+ self.absolute_path = absolute_path
213
18
 
19
+ def __str__(self):
20
+ return self.url_path
214
21
 
215
- def get_finders():
216
- from plain.runtime import settings
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
217
30
 
218
- for finder_path in settings.ASSETS_FINDERS:
219
- yield get_finder(finder_path)
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)
220
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)
221
40
 
222
- @functools.cache
223
- def get_finder(import_path):
224
- """
225
- Import the assets finder class described by import_path, where
226
- import_path is the full Python path to the class.
227
- """
228
- Finder = import_string(import_path)
229
- if not issubclass(Finder, BaseFinder):
230
- raise ImproperlyConfigured(
231
- f'Finder "{Finder}" is not a subclass of "{BaseFinder}"'
232
- )
233
- return Finder()
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]
plain/assets/urls.py ADDED
@@ -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
+ ]