plain 0.1.2__py3-none-any.whl → 0.2.1__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 (38) hide show
  1. plain/assets/README.md +69 -31
  2. plain/assets/__init__.py +0 -6
  3. plain/assets/compile.py +121 -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 +67 -5
  9. plain/packages/config.py +5 -5
  10. plain/packages/registry.py +1 -7
  11. plain/preflight/urls.py +0 -10
  12. plain/runtime/README.md +0 -1
  13. plain/runtime/global_settings.py +5 -16
  14. plain/runtime/user_settings.py +0 -49
  15. plain/templates/jinja/globals.py +1 -1
  16. plain/test/__init__.py +0 -8
  17. plain/test/client.py +36 -16
  18. plain/views/base.py +5 -3
  19. plain/views/errors.py +7 -0
  20. {plain-0.1.2.dist-info → plain-0.2.1.dist-info}/LICENSE +0 -24
  21. {plain-0.1.2.dist-info → plain-0.2.1.dist-info}/METADATA +1 -1
  22. {plain-0.1.2.dist-info → plain-0.2.1.dist-info}/RECORD +24 -34
  23. plain/assets/preflight.py +0 -14
  24. plain/assets/storage.py +0 -916
  25. plain/assets/utils.py +0 -52
  26. plain/assets/whitenoise/__init__.py +0 -5
  27. plain/assets/whitenoise/base.py +0 -259
  28. plain/assets/whitenoise/compress.py +0 -189
  29. plain/assets/whitenoise/media_types.py +0 -137
  30. plain/assets/whitenoise/middleware.py +0 -197
  31. plain/assets/whitenoise/responders.py +0 -286
  32. plain/assets/whitenoise/storage.py +0 -178
  33. plain/assets/whitenoise/string_utils.py +0 -13
  34. plain/internal/legacy/management/commands/__init__.py +0 -0
  35. plain/internal/legacy/management/commands/collectstatic.py +0 -297
  36. plain/test/utils.py +0 -255
  37. {plain-0.1.2.dist-info → plain-0.2.1.dist-info}/WHEEL +0 -0
  38. {plain-0.1.2.dist-info → plain-0.2.1.dist-info}/entry_points.txt +0 -0
plain/assets/README.md CHANGED
@@ -1,56 +1,94 @@
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/).
7
5
 
8
6
  ## Usage
9
7
 
10
- Generally speaking, the simplest way to include assests in your app is to put them either in `app/assets` or `app/<package>/assets`.
8
+ To serve assets, put them in `app/assets` or `app/{package}/assets`.
11
9
 
12
- Then in your template you can use the `asset()` function to get the URL.
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:
13
25
 
14
26
  ```html
15
27
  <link rel="stylesheet" href="{{ asset('css/style.css') }}">
16
28
  ```
17
29
 
18
- If you ever need to reference an asset directly in Python code, you can use the `get_asset_url()` function.
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?
19
52
 
20
53
  ```python
21
- from plain.assets import get_asset_url
54
+ from plain.assets.urls import get_asset_url
55
+
56
+ url = get_asset_url("css/style.css")
57
+ ```
22
58
 
23
- print(get_asset_url("css/style.css"))
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
24
66
  ```
25
67
 
26
- ## Settings
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
+ ```
27
76
 
28
- These are the default settings related to assets handling.
77
+ Use the `ASSETS_BASE_URL` setting to tell the `{{ asset() }}` template function where to point.
29
78
 
30
79
  ```python
31
80
  # 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
- ]
81
+ ASSETS_BASE_URL = "https://cdn.example.com/"
82
+ ```
39
83
 
40
- ASSETS_BACKEND = "plain.assets.whitenoise.storage.CompressedManifestStaticFilesStorage"
41
84
 
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
- ]
85
+ ### Why aren't the originals copied to the compiled directory?
48
86
 
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"
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).
52
88
 
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
- ```
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
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,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
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
+ ]