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.
- plain/assets/README.md +18 -37
- plain/assets/__init__.py +0 -6
- plain/assets/compile.py +111 -0
- plain/assets/finders.py +26 -218
- plain/assets/fingerprints.py +38 -0
- plain/assets/urls.py +31 -0
- plain/assets/views.py +263 -0
- plain/cli/cli.py +68 -5
- plain/csrf/README.md +12 -0
- plain/packages/config.py +5 -5
- plain/packages/registry.py +1 -7
- plain/preflight/urls.py +0 -10
- plain/runtime/README.md +0 -1
- plain/runtime/__init__.py +1 -1
- plain/runtime/global_settings.py +7 -14
- plain/runtime/user_settings.py +0 -49
- plain/templates/jinja/globals.py +1 -1
- plain/test/__init__.py +0 -8
- plain/test/client.py +36 -16
- plain/views/base.py +5 -3
- plain/views/errors.py +7 -0
- {plain-0.1.1.dist-info → plain-0.2.0.dist-info}/LICENSE +0 -24
- {plain-0.1.1.dist-info → plain-0.2.0.dist-info}/METADATA +1 -1
- {plain-0.1.1.dist-info → plain-0.2.0.dist-info}/RECORD +26 -36
- plain/assets/preflight.py +0 -14
- plain/assets/storage.py +0 -916
- plain/assets/utils.py +0 -52
- plain/assets/whitenoise/__init__.py +0 -5
- plain/assets/whitenoise/base.py +0 -259
- plain/assets/whitenoise/compress.py +0 -189
- plain/assets/whitenoise/media_types.py +0 -137
- plain/assets/whitenoise/middleware.py +0 -197
- plain/assets/whitenoise/responders.py +0 -286
- plain/assets/whitenoise/storage.py +0 -178
- plain/assets/whitenoise/string_utils.py +0 -13
- plain/internal/legacy/management/commands/__init__.py +0 -0
- plain/internal/legacy/management/commands/collectstatic.py +0 -297
- plain/test/utils.py +0 -255
- {plain-0.1.1.dist-info → plain-0.2.0.dist-info}/WHEEL +0 -0
- {plain-0.1.1.dist-info → plain-0.2.0.dist-info}/entry_points.txt +0 -0
plain/assets/views.py
ADDED
|
@@ -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
|
plain/cli/cli.py
CHANGED
|
@@ -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.
|
|
274
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
306
|
-
|
|
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()
|
plain/csrf/README.md
CHANGED
plain/packages/config.py
CHANGED
|
@@ -15,11 +15,11 @@ class PackageConfig:
|
|
|
15
15
|
migrations_module = "migrations"
|
|
16
16
|
|
|
17
17
|
def __init__(self, package_name, package_module):
|
|
18
|
-
# Full Python path to the application e.g. 'plain.staff.admin
|
|
18
|
+
# Full Python path to the application e.g. 'plain.staff.admin'.
|
|
19
19
|
self.name = package_name
|
|
20
20
|
|
|
21
|
-
# Root module for the application e.g. <module 'plain.staff.admin
|
|
22
|
-
# from '
|
|
21
|
+
# Root module for the application e.g. <module 'plain.staff.admin'
|
|
22
|
+
# from 'staff/__init__.py'>.
|
|
23
23
|
self.module = package_module
|
|
24
24
|
|
|
25
25
|
# Reference to the Packages registry that holds this PackageConfig. Set by the
|
|
@@ -43,8 +43,8 @@ class PackageConfig:
|
|
|
43
43
|
if not hasattr(self, "path"):
|
|
44
44
|
self.path = self._path_from_module(package_module)
|
|
45
45
|
|
|
46
|
-
# Module containing models e.g. <module 'plain.staff.
|
|
47
|
-
# from '
|
|
46
|
+
# Module containing models e.g. <module 'plain.staff.models'
|
|
47
|
+
# from 'staff/models.py'>. Set by import_models().
|
|
48
48
|
# None if the application doesn't have a models module.
|
|
49
49
|
self.models_module = None
|
|
50
50
|
|
plain/packages/registry.py
CHANGED
|
@@ -243,7 +243,7 @@ class Packages:
|
|
|
243
243
|
"""
|
|
244
244
|
Check whether an application with this name exists in the registry.
|
|
245
245
|
|
|
246
|
-
package_name is the full name of the app e.g. 'plain.staff
|
|
246
|
+
package_name is the full name of the app e.g. 'plain.staff'.
|
|
247
247
|
"""
|
|
248
248
|
self.check_packages_ready()
|
|
249
249
|
return any(ac.name == package_name for ac in self.package_configs.values())
|
|
@@ -363,12 +363,6 @@ class Packages:
|
|
|
363
363
|
self.clear_cache()
|
|
364
364
|
self.populate(installed)
|
|
365
365
|
|
|
366
|
-
def unset_installed_packages(self):
|
|
367
|
-
"""Cancel a previous call to set_installed_packages()."""
|
|
368
|
-
self.package_configs = self.stored_package_configs.pop()
|
|
369
|
-
self.packages_ready = self.models_ready = self.ready = True
|
|
370
|
-
self.clear_cache()
|
|
371
|
-
|
|
372
366
|
def clear_cache(self):
|
|
373
367
|
"""
|
|
374
368
|
Clear all internal caches, for methods that alter the app registry.
|
plain/preflight/urls.py
CHANGED
|
@@ -100,16 +100,6 @@ def get_warning_for_invalid_pattern(pattern):
|
|
|
100
100
|
]
|
|
101
101
|
|
|
102
102
|
|
|
103
|
-
@register
|
|
104
|
-
def check_url_settings(package_configs, **kwargs):
|
|
105
|
-
errors = []
|
|
106
|
-
for name in ("ASSETS_URL",):
|
|
107
|
-
value = getattr(settings, name)
|
|
108
|
-
if value and not value.endswith("/"):
|
|
109
|
-
errors.append(E006(name))
|
|
110
|
-
return errors
|
|
111
|
-
|
|
112
|
-
|
|
113
103
|
def E006(name):
|
|
114
104
|
return Error(
|
|
115
105
|
f"The {name} setting must end with a slash.",
|
plain/runtime/README.md
CHANGED
|
@@ -55,7 +55,6 @@ DEBUG = environ.get("DEBUG", "false").lower() in ("true", "1", "yes")
|
|
|
55
55
|
|
|
56
56
|
MIDDLEWARE = [
|
|
57
57
|
"plain.middleware.security.SecurityMiddleware",
|
|
58
|
-
"plain.assets.whitenoise.middleware.WhiteNoiseMiddleware",
|
|
59
58
|
"plain.sessions.middleware.SessionMiddleware",
|
|
60
59
|
"plain.middleware.common.CommonMiddleware",
|
|
61
60
|
"plain.csrf.middleware.CsrfViewMiddleware",
|
plain/runtime/__init__.py
CHANGED
|
@@ -8,7 +8,7 @@ from dotenv import load_dotenv
|
|
|
8
8
|
from .user_settings import LazySettings
|
|
9
9
|
|
|
10
10
|
try:
|
|
11
|
-
__version__ = importlib.metadata.version("
|
|
11
|
+
__version__ = importlib.metadata.version("plain")
|
|
12
12
|
except importlib.metadata.PackageNotFoundError:
|
|
13
13
|
__version__ = "dev"
|
|
14
14
|
|
plain/runtime/global_settings.py
CHANGED
|
@@ -112,7 +112,6 @@ SECURE_PROXY_SSL_HEADER = None
|
|
|
112
112
|
# phase the middleware will be applied in reverse order.
|
|
113
113
|
MIDDLEWARE = [
|
|
114
114
|
"plain.middleware.security.SecurityMiddleware",
|
|
115
|
-
"plain.assets.whitenoise.middleware.WhiteNoiseMiddleware",
|
|
116
115
|
"plain.middleware.common.CommonMiddleware",
|
|
117
116
|
"plain.csrf.middleware.CsrfViewMiddleware",
|
|
118
117
|
"plain.middleware.clickjacking.XFrameOptionsMiddleware",
|
|
@@ -151,22 +150,16 @@ LOGGING = {}
|
|
|
151
150
|
# ASSETS #
|
|
152
151
|
###############
|
|
153
152
|
|
|
154
|
-
ASSETS_BACKEND = "plain.assets.whitenoise.storage.CompressedManifestStaticFilesStorage"
|
|
155
|
-
|
|
156
|
-
# List of finder classes that know how to find assets files in
|
|
157
|
-
# various locations.
|
|
158
|
-
ASSETS_FINDERS = [
|
|
159
|
-
"plain.assets.finders.FileSystemFinder",
|
|
160
|
-
"plain.assets.finders.PackageDirectoriesFinder",
|
|
161
|
-
]
|
|
162
|
-
|
|
163
153
|
# Absolute path to the directory assets files should be collected to.
|
|
164
154
|
# Example: "/var/www/example.com/assets/"
|
|
165
|
-
|
|
155
|
+
ASSETS_COMPILED_PATH = PLAIN_TEMP_PATH / "assets" / "compiled"
|
|
156
|
+
|
|
157
|
+
# Whether to redirect the original asset path to the fingerprinted path.
|
|
158
|
+
ASSETS_REDIRECT_ORIGINAL = True
|
|
166
159
|
|
|
167
|
-
#
|
|
168
|
-
#
|
|
169
|
-
|
|
160
|
+
# If assets are served by a CDN, use this URL to prefix asset paths.
|
|
161
|
+
# Ex. "https://cdn.example.com/assets/"
|
|
162
|
+
ASSETS_BASE_URL: str = ""
|
|
170
163
|
|
|
171
164
|
####################
|
|
172
165
|
# PREFLIGHT CHECKS #
|
plain/runtime/user_settings.py
CHANGED
|
@@ -302,52 +302,3 @@ class Settings:
|
|
|
302
302
|
|
|
303
303
|
def __repr__(self):
|
|
304
304
|
return f'<{self.__class__.__name__} "{self.SETTINGS_MODULE}">'
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
# Currently used for test settings override... nothing else
|
|
308
|
-
class UserSettingsHolder:
|
|
309
|
-
"""Holder for user configured settings."""
|
|
310
|
-
|
|
311
|
-
# SETTINGS_MODULE doesn't make much sense in the manually configured
|
|
312
|
-
# (standalone) case.
|
|
313
|
-
SETTINGS_MODULE = None
|
|
314
|
-
|
|
315
|
-
def __init__(self, default_settings):
|
|
316
|
-
"""
|
|
317
|
-
Requests for configuration variables not in this class are satisfied
|
|
318
|
-
from the module specified in default_settings (if possible).
|
|
319
|
-
"""
|
|
320
|
-
self.__dict__["_deleted"] = set()
|
|
321
|
-
self.default_settings = default_settings
|
|
322
|
-
|
|
323
|
-
def __getattr__(self, name):
|
|
324
|
-
if not name.isupper() or name in self._deleted:
|
|
325
|
-
raise AttributeError
|
|
326
|
-
return getattr(self.default_settings, name)
|
|
327
|
-
|
|
328
|
-
def __setattr__(self, name, value):
|
|
329
|
-
self._deleted.discard(name)
|
|
330
|
-
super().__setattr__(name, value)
|
|
331
|
-
|
|
332
|
-
def __delattr__(self, name):
|
|
333
|
-
self._deleted.add(name)
|
|
334
|
-
if hasattr(self, name):
|
|
335
|
-
super().__delattr__(name)
|
|
336
|
-
|
|
337
|
-
def __dir__(self):
|
|
338
|
-
return sorted(
|
|
339
|
-
s
|
|
340
|
-
for s in [*self.__dict__, *dir(self.default_settings)]
|
|
341
|
-
if s not in self._deleted
|
|
342
|
-
)
|
|
343
|
-
|
|
344
|
-
def is_overridden(self, setting):
|
|
345
|
-
deleted = setting in self._deleted
|
|
346
|
-
set_locally = setting in self.__dict__
|
|
347
|
-
set_on_default = getattr(
|
|
348
|
-
self.default_settings, "is_overridden", lambda s: False
|
|
349
|
-
)(setting)
|
|
350
|
-
return deleted or set_locally or set_on_default
|
|
351
|
-
|
|
352
|
-
def __repr__(self):
|
|
353
|
-
return f"<{self.__class__.__name__}>"
|
plain/templates/jinja/globals.py
CHANGED
plain/test/__init__.py
CHANGED
|
@@ -1,16 +1,8 @@
|
|
|
1
1
|
"""Plain Unit Test framework."""
|
|
2
2
|
|
|
3
3
|
from plain.test.client import Client, RequestFactory
|
|
4
|
-
from plain.test.utils import (
|
|
5
|
-
ignore_warnings,
|
|
6
|
-
modify_settings,
|
|
7
|
-
override_settings,
|
|
8
|
-
)
|
|
9
4
|
|
|
10
5
|
__all__ = [
|
|
11
6
|
"Client",
|
|
12
7
|
"RequestFactory",
|
|
13
|
-
"ignore_warnings",
|
|
14
|
-
"modify_settings",
|
|
15
|
-
"override_settings",
|
|
16
8
|
]
|
plain/test/client.py
CHANGED
|
@@ -8,6 +8,7 @@ from http import HTTPStatus
|
|
|
8
8
|
from http.cookies import SimpleCookie
|
|
9
9
|
from importlib import import_module
|
|
10
10
|
from io import BytesIO, IOBase
|
|
11
|
+
from itertools import chain
|
|
11
12
|
from urllib.parse import unquote_to_bytes, urljoin, urlparse, urlsplit
|
|
12
13
|
|
|
13
14
|
from plain.http import HttpHeaders, HttpRequest, QueryDict
|
|
@@ -16,7 +17,6 @@ from plain.internal.handlers.wsgi import WSGIRequest
|
|
|
16
17
|
from plain.json import PlainJSONEncoder
|
|
17
18
|
from plain.runtime import settings
|
|
18
19
|
from plain.signals import got_request_exception, request_started
|
|
19
|
-
from plain.test.utils import ContextList
|
|
20
20
|
from plain.urls import resolve
|
|
21
21
|
from plain.utils.encoding import force_bytes
|
|
22
22
|
from plain.utils.functional import SimpleLazyObject
|
|
@@ -40,6 +40,41 @@ CONTENT_TYPE_RE = _lazy_re_compile(r".*; charset=([\w-]+);?")
|
|
|
40
40
|
JSON_CONTENT_TYPE_RE = _lazy_re_compile(r"^application\/(.+\+)?json")
|
|
41
41
|
|
|
42
42
|
|
|
43
|
+
class ContextList(list):
|
|
44
|
+
"""
|
|
45
|
+
A wrapper that provides direct key access to context items contained
|
|
46
|
+
in a list of context objects.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
def __getitem__(self, key):
|
|
50
|
+
if isinstance(key, str):
|
|
51
|
+
for subcontext in self:
|
|
52
|
+
if key in subcontext:
|
|
53
|
+
return subcontext[key]
|
|
54
|
+
raise KeyError(key)
|
|
55
|
+
else:
|
|
56
|
+
return super().__getitem__(key)
|
|
57
|
+
|
|
58
|
+
def get(self, key, default=None):
|
|
59
|
+
try:
|
|
60
|
+
return self.__getitem__(key)
|
|
61
|
+
except KeyError:
|
|
62
|
+
return default
|
|
63
|
+
|
|
64
|
+
def __contains__(self, key):
|
|
65
|
+
try:
|
|
66
|
+
self[key]
|
|
67
|
+
except KeyError:
|
|
68
|
+
return False
|
|
69
|
+
return True
|
|
70
|
+
|
|
71
|
+
def keys(self):
|
|
72
|
+
"""
|
|
73
|
+
Flattened keys of subcontexts.
|
|
74
|
+
"""
|
|
75
|
+
return set(chain.from_iterable(d for subcontext in self for d in subcontext))
|
|
76
|
+
|
|
77
|
+
|
|
43
78
|
class RedirectCycleError(Exception):
|
|
44
79
|
"""The test client has been asked to follow a redirect loop."""
|
|
45
80
|
|
|
@@ -550,21 +585,6 @@ class ClientMixin:
|
|
|
550
585
|
self.cookies[settings.SESSION_COOKIE_NAME] = session.session_key
|
|
551
586
|
return session
|
|
552
587
|
|
|
553
|
-
def login(self, **credentials):
|
|
554
|
-
"""
|
|
555
|
-
Set the Factory to appear as if it has successfully logged into a site.
|
|
556
|
-
|
|
557
|
-
Return True if login is possible or False if the provided credentials
|
|
558
|
-
are incorrect.
|
|
559
|
-
"""
|
|
560
|
-
from plain.auth import authenticate
|
|
561
|
-
|
|
562
|
-
user = authenticate(**credentials)
|
|
563
|
-
if user:
|
|
564
|
-
self._login(user)
|
|
565
|
-
return True
|
|
566
|
-
return False
|
|
567
|
-
|
|
568
588
|
def force_login(self, user):
|
|
569
589
|
self._login(user)
|
|
570
590
|
|