plain 0.1.2__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/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/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.2.dist-info → plain-0.2.0.dist-info}/LICENSE +0 -24
- {plain-0.1.2.dist-info → plain-0.2.0.dist-info}/METADATA +1 -1
- {plain-0.1.2.dist-info → plain-0.2.0.dist-info}/RECORD +24 -34
- 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.2.dist-info → plain-0.2.0.dist-info}/WHEEL +0 -0
- {plain-0.1.2.dist-info → plain-0.2.0.dist-info}/entry_points.txt +0 -0
|
@@ -1,197 +0,0 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
import os
|
|
4
|
-
from posixpath import basename
|
|
5
|
-
from urllib.parse import urlparse
|
|
6
|
-
|
|
7
|
-
from plain.assets import finders
|
|
8
|
-
from plain.assets.storage import assets_storage
|
|
9
|
-
from plain.http import FileResponse
|
|
10
|
-
from plain.runtime import settings
|
|
11
|
-
|
|
12
|
-
from .base import WhiteNoise
|
|
13
|
-
from .string_utils import ensure_leading_trailing_slash
|
|
14
|
-
|
|
15
|
-
__all__ = ["WhiteNoiseMiddleware"]
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
class WhiteNoiseFileResponse(FileResponse):
|
|
19
|
-
"""
|
|
20
|
-
Wrap Plain's FileResponse to prevent setting any default headers. For the
|
|
21
|
-
most part these just duplicate work already done by WhiteNoise but in some
|
|
22
|
-
cases (e.g. the content-disposition header introduced in Plain 3.0) they
|
|
23
|
-
are actively harmful.
|
|
24
|
-
"""
|
|
25
|
-
|
|
26
|
-
def set_headers(self, *args, **kwargs):
|
|
27
|
-
pass
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
class WhiteNoiseMiddleware(WhiteNoise):
|
|
31
|
-
"""
|
|
32
|
-
Wrap WhiteNoise to allow it to function as Plain middleware, rather
|
|
33
|
-
than WSGI middleware.
|
|
34
|
-
"""
|
|
35
|
-
|
|
36
|
-
def __init__(self, get_response=None, settings=settings):
|
|
37
|
-
self.get_response = get_response
|
|
38
|
-
|
|
39
|
-
try:
|
|
40
|
-
autorefresh: bool = settings.WHITENOISE_AUTOREFRESH
|
|
41
|
-
except AttributeError:
|
|
42
|
-
autorefresh = settings.DEBUG
|
|
43
|
-
try:
|
|
44
|
-
max_age = settings.WHITENOISE_MAX_AGE
|
|
45
|
-
except AttributeError:
|
|
46
|
-
if settings.DEBUG:
|
|
47
|
-
max_age = 0
|
|
48
|
-
else:
|
|
49
|
-
max_age = 60
|
|
50
|
-
try:
|
|
51
|
-
allow_all_origins = settings.WHITENOISE_ALLOW_ALL_ORIGINS
|
|
52
|
-
except AttributeError:
|
|
53
|
-
allow_all_origins = True
|
|
54
|
-
try:
|
|
55
|
-
charset = settings.WHITENOISE_CHARSET
|
|
56
|
-
except AttributeError:
|
|
57
|
-
charset = "utf-8"
|
|
58
|
-
try:
|
|
59
|
-
mimetypes = settings.WHITENOISE_MIMETYPES
|
|
60
|
-
except AttributeError:
|
|
61
|
-
mimetypes = None
|
|
62
|
-
try:
|
|
63
|
-
add_headers_function = settings.WHITENOISE_ADD_HEADERS_FUNCTION
|
|
64
|
-
except AttributeError:
|
|
65
|
-
add_headers_function = None
|
|
66
|
-
try:
|
|
67
|
-
index_file = settings.WHITENOISE_INDEX_FILE
|
|
68
|
-
except AttributeError:
|
|
69
|
-
index_file = None
|
|
70
|
-
try:
|
|
71
|
-
immutable_file_test = settings.WHITENOISE_IMMUTABLE_FILE_TEST
|
|
72
|
-
except AttributeError:
|
|
73
|
-
immutable_file_test = None
|
|
74
|
-
|
|
75
|
-
super().__init__(
|
|
76
|
-
application=None,
|
|
77
|
-
autorefresh=autorefresh,
|
|
78
|
-
max_age=max_age,
|
|
79
|
-
allow_all_origins=allow_all_origins,
|
|
80
|
-
charset=charset,
|
|
81
|
-
mimetypes=mimetypes,
|
|
82
|
-
add_headers_function=add_headers_function,
|
|
83
|
-
index_file=index_file,
|
|
84
|
-
immutable_file_test=immutable_file_test,
|
|
85
|
-
)
|
|
86
|
-
|
|
87
|
-
try:
|
|
88
|
-
self.use_finders = settings.WHITENOISE_USE_FINDERS
|
|
89
|
-
except AttributeError:
|
|
90
|
-
self.use_finders = settings.DEBUG
|
|
91
|
-
|
|
92
|
-
try:
|
|
93
|
-
self.static_prefix = settings.WHITENOISE_STATIC_PREFIX
|
|
94
|
-
except AttributeError:
|
|
95
|
-
self.static_prefix = urlparse(settings.ASSETS_URL or "").path
|
|
96
|
-
self.static_prefix = ensure_leading_trailing_slash(self.static_prefix)
|
|
97
|
-
|
|
98
|
-
self.static_root = settings.ASSETS_ROOT
|
|
99
|
-
if self.static_root:
|
|
100
|
-
self.add_files(self.static_root, prefix=self.static_prefix)
|
|
101
|
-
|
|
102
|
-
try:
|
|
103
|
-
root = settings.WHITENOISE_ROOT
|
|
104
|
-
except AttributeError:
|
|
105
|
-
root = None
|
|
106
|
-
if root:
|
|
107
|
-
self.add_files(root)
|
|
108
|
-
|
|
109
|
-
if self.use_finders and not self.autorefresh:
|
|
110
|
-
self.add_files_from_finders()
|
|
111
|
-
|
|
112
|
-
def __call__(self, request):
|
|
113
|
-
if self.autorefresh:
|
|
114
|
-
asset_file = self.find_file(request.path_info)
|
|
115
|
-
else:
|
|
116
|
-
asset_file = self.files.get(request.path_info)
|
|
117
|
-
if asset_file is not None:
|
|
118
|
-
return self.serve(asset_file, request)
|
|
119
|
-
return self.get_response(request)
|
|
120
|
-
|
|
121
|
-
@staticmethod
|
|
122
|
-
def serve(asset_file, request):
|
|
123
|
-
response = asset_file.get_response(request.method, request.META)
|
|
124
|
-
status = int(response.status)
|
|
125
|
-
http_response = WhiteNoiseFileResponse(response.file or (), status=status)
|
|
126
|
-
# Remove default content-type
|
|
127
|
-
del http_response["content-type"]
|
|
128
|
-
for key, value in response.headers:
|
|
129
|
-
http_response[key] = value
|
|
130
|
-
return http_response
|
|
131
|
-
|
|
132
|
-
def add_files_from_finders(self):
|
|
133
|
-
files = {}
|
|
134
|
-
for finder in finders.get_finders():
|
|
135
|
-
for path, storage in finder.list(None):
|
|
136
|
-
prefix = (getattr(storage, "prefix", None) or "").strip("/")
|
|
137
|
-
url = "".join(
|
|
138
|
-
(
|
|
139
|
-
self.static_prefix,
|
|
140
|
-
prefix,
|
|
141
|
-
"/" if prefix else "",
|
|
142
|
-
path.replace("\\", "/"),
|
|
143
|
-
)
|
|
144
|
-
)
|
|
145
|
-
# Use setdefault as only first matching file should be used
|
|
146
|
-
files.setdefault(url, storage.path(path))
|
|
147
|
-
stat_cache = {path: os.stat(path) for path in files.values()}
|
|
148
|
-
for url, path in files.items():
|
|
149
|
-
self.add_file_to_dictionary(url, path, stat_cache=stat_cache)
|
|
150
|
-
|
|
151
|
-
def candidate_paths_for_url(self, url):
|
|
152
|
-
if self.use_finders and url.startswith(self.static_prefix):
|
|
153
|
-
path = finders.find(url[len(self.static_prefix) :])
|
|
154
|
-
if path:
|
|
155
|
-
yield path
|
|
156
|
-
paths = super().candidate_paths_for_url(url)
|
|
157
|
-
for path in paths:
|
|
158
|
-
yield path
|
|
159
|
-
|
|
160
|
-
def immutable_file_test(self, path, url):
|
|
161
|
-
"""
|
|
162
|
-
Determine whether given URL represents an immutable file (i.e. a
|
|
163
|
-
file with a hash of its contents as part of its name) which can
|
|
164
|
-
therefore be cached forever
|
|
165
|
-
"""
|
|
166
|
-
if not url.startswith(self.static_prefix):
|
|
167
|
-
return False
|
|
168
|
-
name = url[len(self.static_prefix) :]
|
|
169
|
-
name_without_hash = self.get_name_without_hash(name)
|
|
170
|
-
if name == name_without_hash:
|
|
171
|
-
return False
|
|
172
|
-
asset_url = self.get_asset_url(name_without_hash)
|
|
173
|
-
# If the asset_url function maps the name without hash
|
|
174
|
-
# back to the original name, then we know we've got a
|
|
175
|
-
# versioned filename
|
|
176
|
-
if asset_url and basename(asset_url) == basename(url):
|
|
177
|
-
return True
|
|
178
|
-
return False
|
|
179
|
-
|
|
180
|
-
def get_name_without_hash(self, filename):
|
|
181
|
-
"""
|
|
182
|
-
Removes the version hash from a filename e.g, transforms
|
|
183
|
-
'css/application.f3ea4bcc2.css' into 'css/application.css'
|
|
184
|
-
|
|
185
|
-
Note: this is specific to the naming scheme used by Plain's
|
|
186
|
-
CachedStaticFilesStorage. You may have to override this if
|
|
187
|
-
you are using a different static files versioning system
|
|
188
|
-
"""
|
|
189
|
-
name_with_hash, ext = os.path.splitext(filename)
|
|
190
|
-
name = os.path.splitext(name_with_hash)[0]
|
|
191
|
-
return name + ext
|
|
192
|
-
|
|
193
|
-
def get_asset_url(self, name):
|
|
194
|
-
try:
|
|
195
|
-
return assets_storage.url(name)
|
|
196
|
-
except ValueError:
|
|
197
|
-
return None
|
|
@@ -1,286 +0,0 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
import errno
|
|
4
|
-
import os
|
|
5
|
-
import re
|
|
6
|
-
import stat
|
|
7
|
-
from email.utils import formatdate, parsedate
|
|
8
|
-
from http import HTTPStatus
|
|
9
|
-
from io import BufferedIOBase
|
|
10
|
-
from time import mktime
|
|
11
|
-
from urllib.parse import quote
|
|
12
|
-
from wsgiref.headers import Headers
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
class Response:
|
|
16
|
-
__slots__ = ("status", "headers", "file")
|
|
17
|
-
|
|
18
|
-
def __init__(self, status, headers, file):
|
|
19
|
-
self.status = status
|
|
20
|
-
self.headers = headers
|
|
21
|
-
self.file = file
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
NOT_ALLOWED_RESPONSE = Response(
|
|
25
|
-
status=HTTPStatus.METHOD_NOT_ALLOWED,
|
|
26
|
-
headers=[("Allow", "GET, HEAD")],
|
|
27
|
-
file=None,
|
|
28
|
-
)
|
|
29
|
-
|
|
30
|
-
# Headers which should be returned with a 304 Not Modified response as
|
|
31
|
-
# specified here: https://tools.ietf.org/html/rfc7232#section-4.1
|
|
32
|
-
NOT_MODIFIED_HEADERS = (
|
|
33
|
-
"Cache-Control",
|
|
34
|
-
"Content-Location",
|
|
35
|
-
"Date",
|
|
36
|
-
"ETag",
|
|
37
|
-
"Expires",
|
|
38
|
-
"Vary",
|
|
39
|
-
)
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
class SlicedFile(BufferedIOBase):
|
|
43
|
-
"""
|
|
44
|
-
A file like wrapper to handle seeking to the start byte of a range request
|
|
45
|
-
and to return no further output once the end byte of a range request has
|
|
46
|
-
been reached.
|
|
47
|
-
"""
|
|
48
|
-
|
|
49
|
-
def __init__(self, fileobj, start, end):
|
|
50
|
-
fileobj.seek(start)
|
|
51
|
-
self.fileobj = fileobj
|
|
52
|
-
self.remaining = end - start + 1
|
|
53
|
-
|
|
54
|
-
def read(self, size=-1):
|
|
55
|
-
if self.remaining <= 0:
|
|
56
|
-
return b""
|
|
57
|
-
if size < 0:
|
|
58
|
-
size = self.remaining
|
|
59
|
-
else:
|
|
60
|
-
size = min(size, self.remaining)
|
|
61
|
-
data = self.fileobj.read(size)
|
|
62
|
-
self.remaining -= len(data)
|
|
63
|
-
return data
|
|
64
|
-
|
|
65
|
-
def close(self):
|
|
66
|
-
self.fileobj.close()
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
class StaticFile:
|
|
70
|
-
def __init__(self, path, headers, encodings=None, stat_cache=None):
|
|
71
|
-
files = self.get_file_stats(path, encodings, stat_cache)
|
|
72
|
-
headers = self.get_headers(headers, files)
|
|
73
|
-
self.last_modified = parsedate(headers["Last-Modified"])
|
|
74
|
-
self.etag = headers["ETag"]
|
|
75
|
-
self.not_modified_response = self.get_not_modified_response(headers)
|
|
76
|
-
self.alternatives = self.get_alternatives(headers, files)
|
|
77
|
-
|
|
78
|
-
def get_response(self, method, request_headers):
|
|
79
|
-
if method not in ("GET", "HEAD"):
|
|
80
|
-
return NOT_ALLOWED_RESPONSE
|
|
81
|
-
if self.is_not_modified(request_headers):
|
|
82
|
-
return self.not_modified_response
|
|
83
|
-
path, headers = self.get_path_and_headers(request_headers)
|
|
84
|
-
if method != "HEAD":
|
|
85
|
-
file_handle = open(path, "rb")
|
|
86
|
-
else:
|
|
87
|
-
file_handle = None
|
|
88
|
-
range_header = request_headers.get("HTTP_RANGE")
|
|
89
|
-
if range_header:
|
|
90
|
-
try:
|
|
91
|
-
return self.get_range_response(range_header, headers, file_handle)
|
|
92
|
-
except ValueError:
|
|
93
|
-
# If we can't interpret the Range request for any reason then
|
|
94
|
-
# just ignore it and return the standard response (this
|
|
95
|
-
# behaviour is allowed by the spec)
|
|
96
|
-
pass
|
|
97
|
-
return Response(HTTPStatus.OK, headers, file_handle)
|
|
98
|
-
|
|
99
|
-
def get_range_response(self, range_header, base_headers, file_handle):
|
|
100
|
-
headers = []
|
|
101
|
-
for item in base_headers:
|
|
102
|
-
if item[0] == "Content-Length":
|
|
103
|
-
size = int(item[1])
|
|
104
|
-
else:
|
|
105
|
-
headers.append(item)
|
|
106
|
-
start, end = self.get_byte_range(range_header, size)
|
|
107
|
-
if start >= end:
|
|
108
|
-
return self.get_range_not_satisfiable_response(file_handle, size)
|
|
109
|
-
if file_handle is not None:
|
|
110
|
-
file_handle = SlicedFile(file_handle, start, end)
|
|
111
|
-
headers.append(("Content-Range", f"bytes {start}-{end}/{size}"))
|
|
112
|
-
headers.append(("Content-Length", str(end - start + 1)))
|
|
113
|
-
return Response(HTTPStatus.PARTIAL_CONTENT, headers, file_handle)
|
|
114
|
-
|
|
115
|
-
def get_byte_range(self, range_header, size):
|
|
116
|
-
start, end = self.parse_byte_range(range_header)
|
|
117
|
-
if start < 0:
|
|
118
|
-
start = max(start + size, 0)
|
|
119
|
-
if end is None:
|
|
120
|
-
end = size - 1
|
|
121
|
-
else:
|
|
122
|
-
end = min(end, size - 1)
|
|
123
|
-
return start, end
|
|
124
|
-
|
|
125
|
-
@staticmethod
|
|
126
|
-
def parse_byte_range(range_header):
|
|
127
|
-
units, _, range_spec = range_header.strip().partition("=")
|
|
128
|
-
if units != "bytes":
|
|
129
|
-
raise ValueError()
|
|
130
|
-
# Only handle a single range spec. Multiple ranges will trigger a
|
|
131
|
-
# ValueError below which will result in the Range header being ignored
|
|
132
|
-
start_str, sep, end_str = range_spec.strip().partition("-")
|
|
133
|
-
if not sep:
|
|
134
|
-
raise ValueError()
|
|
135
|
-
if not start_str:
|
|
136
|
-
start = -int(end_str)
|
|
137
|
-
end = None
|
|
138
|
-
else:
|
|
139
|
-
start = int(start_str)
|
|
140
|
-
end = int(end_str) if end_str else None
|
|
141
|
-
return start, end
|
|
142
|
-
|
|
143
|
-
@staticmethod
|
|
144
|
-
def get_range_not_satisfiable_response(file_handle, size):
|
|
145
|
-
if file_handle is not None:
|
|
146
|
-
file_handle.close()
|
|
147
|
-
return Response(
|
|
148
|
-
HTTPStatus.REQUESTED_RANGE_NOT_SATISFIABLE,
|
|
149
|
-
[("Content-Range", f"bytes */{size}")],
|
|
150
|
-
None,
|
|
151
|
-
)
|
|
152
|
-
|
|
153
|
-
@staticmethod
|
|
154
|
-
def get_file_stats(path, encodings, stat_cache):
|
|
155
|
-
# Primary file has an encoding of None
|
|
156
|
-
files = {None: FileEntry(path, stat_cache)}
|
|
157
|
-
if encodings:
|
|
158
|
-
for encoding, alt_path in encodings.items():
|
|
159
|
-
try:
|
|
160
|
-
files[encoding] = FileEntry(alt_path, stat_cache)
|
|
161
|
-
except MissingFileError:
|
|
162
|
-
continue
|
|
163
|
-
return files
|
|
164
|
-
|
|
165
|
-
def get_headers(self, headers_list, files):
|
|
166
|
-
headers = Headers(headers_list)
|
|
167
|
-
main_file = files[None]
|
|
168
|
-
if len(files) > 1:
|
|
169
|
-
headers["Vary"] = "Accept-Encoding"
|
|
170
|
-
if "Last-Modified" not in headers:
|
|
171
|
-
mtime = main_file.mtime
|
|
172
|
-
# Not all filesystems report mtimes, and sometimes they report an
|
|
173
|
-
# mtime of 0 which we know is incorrect
|
|
174
|
-
if mtime:
|
|
175
|
-
headers["Last-Modified"] = formatdate(mtime, usegmt=True)
|
|
176
|
-
if "ETag" not in headers:
|
|
177
|
-
last_modified = parsedate(headers["Last-Modified"])
|
|
178
|
-
if last_modified:
|
|
179
|
-
timestamp = int(mktime(last_modified))
|
|
180
|
-
headers["ETag"] = f'"{timestamp:x}-{main_file.size:x}"'
|
|
181
|
-
return headers
|
|
182
|
-
|
|
183
|
-
@staticmethod
|
|
184
|
-
def get_not_modified_response(headers):
|
|
185
|
-
not_modified_headers = []
|
|
186
|
-
for key in NOT_MODIFIED_HEADERS:
|
|
187
|
-
if key in headers:
|
|
188
|
-
not_modified_headers.append((key, headers[key]))
|
|
189
|
-
return Response(
|
|
190
|
-
status=HTTPStatus.NOT_MODIFIED, headers=not_modified_headers, file=None
|
|
191
|
-
)
|
|
192
|
-
|
|
193
|
-
@staticmethod
|
|
194
|
-
def get_alternatives(base_headers, files):
|
|
195
|
-
# Sort by size so that the smallest compressed alternative matches first
|
|
196
|
-
alternatives = []
|
|
197
|
-
files_by_size = sorted(files.items(), key=lambda i: i[1].size)
|
|
198
|
-
for encoding, file_entry in files_by_size:
|
|
199
|
-
headers = Headers(base_headers.items())
|
|
200
|
-
headers["Content-Length"] = str(file_entry.size)
|
|
201
|
-
if encoding:
|
|
202
|
-
headers["Content-Encoding"] = encoding
|
|
203
|
-
encoding_re = re.compile(r"\b%s\b" % encoding)
|
|
204
|
-
else:
|
|
205
|
-
encoding_re = re.compile("")
|
|
206
|
-
alternatives.append((encoding_re, file_entry.path, headers.items()))
|
|
207
|
-
return alternatives
|
|
208
|
-
|
|
209
|
-
def is_not_modified(self, request_headers):
|
|
210
|
-
previous_etag = request_headers.get("HTTP_IF_NONE_MATCH")
|
|
211
|
-
if previous_etag is not None:
|
|
212
|
-
return previous_etag == self.etag
|
|
213
|
-
if self.last_modified is None:
|
|
214
|
-
return False
|
|
215
|
-
try:
|
|
216
|
-
last_requested = request_headers["HTTP_IF_MODIFIED_SINCE"]
|
|
217
|
-
except KeyError:
|
|
218
|
-
return False
|
|
219
|
-
last_requested_ts = parsedate(last_requested)
|
|
220
|
-
if last_requested_ts is not None:
|
|
221
|
-
return last_requested_ts >= self.last_modified
|
|
222
|
-
return False
|
|
223
|
-
|
|
224
|
-
def get_path_and_headers(self, request_headers):
|
|
225
|
-
accept_encoding = request_headers.get("HTTP_ACCEPT_ENCODING", "")
|
|
226
|
-
if accept_encoding == "*":
|
|
227
|
-
accept_encoding = ""
|
|
228
|
-
# These are sorted by size so first match is the best
|
|
229
|
-
for encoding_re, path, headers in self.alternatives:
|
|
230
|
-
if encoding_re.search(accept_encoding):
|
|
231
|
-
return path, headers
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
class Redirect:
|
|
235
|
-
def __init__(self, location, headers=None):
|
|
236
|
-
headers = list(headers.items()) if headers else []
|
|
237
|
-
headers.append(("Location", quote(location.encode("utf8"))))
|
|
238
|
-
self.response = Response(HTTPStatus.FOUND, headers, None)
|
|
239
|
-
|
|
240
|
-
def get_response(self, method, request_headers):
|
|
241
|
-
return self.response
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
class NotARegularFileError(Exception):
|
|
245
|
-
pass
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
class MissingFileError(NotARegularFileError):
|
|
249
|
-
pass
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
class IsDirectoryError(MissingFileError):
|
|
253
|
-
pass
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
class FileEntry:
|
|
257
|
-
__slots__ = ("path", "size", "mtime")
|
|
258
|
-
|
|
259
|
-
def __init__(self, path, stat_cache=None):
|
|
260
|
-
self.path = path
|
|
261
|
-
stat_function = os.stat if stat_cache is None else stat_cache.__getitem__
|
|
262
|
-
stat = self.stat_regular_file(path, stat_function)
|
|
263
|
-
self.size = stat.st_size
|
|
264
|
-
self.mtime = stat.st_mtime
|
|
265
|
-
|
|
266
|
-
@staticmethod
|
|
267
|
-
def stat_regular_file(path, stat_function):
|
|
268
|
-
"""
|
|
269
|
-
Wrap `stat_function` to raise appropriate errors if `path` is not a
|
|
270
|
-
regular file
|
|
271
|
-
"""
|
|
272
|
-
try:
|
|
273
|
-
stat_result = stat_function(path)
|
|
274
|
-
except KeyError:
|
|
275
|
-
raise MissingFileError(path)
|
|
276
|
-
except OSError as e:
|
|
277
|
-
if e.errno in (errno.ENOENT, errno.ENAMETOOLONG):
|
|
278
|
-
raise MissingFileError(path)
|
|
279
|
-
else:
|
|
280
|
-
raise
|
|
281
|
-
if not stat.S_ISREG(stat_result.st_mode):
|
|
282
|
-
if stat.S_ISDIR(stat_result.st_mode):
|
|
283
|
-
raise IsDirectoryError(f"Path is a directory: {path}")
|
|
284
|
-
else:
|
|
285
|
-
raise NotARegularFileError(f"Not a regular file: {path}")
|
|
286
|
-
return stat_result
|
|
@@ -1,178 +0,0 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
import errno
|
|
4
|
-
import os
|
|
5
|
-
import re
|
|
6
|
-
import textwrap
|
|
7
|
-
from collections.abc import Iterator
|
|
8
|
-
from typing import Any
|
|
9
|
-
|
|
10
|
-
from plain.assets.storage import (
|
|
11
|
-
ManifestStaticFilesStorage,
|
|
12
|
-
StaticFilesStorage,
|
|
13
|
-
)
|
|
14
|
-
from plain.runtime import settings
|
|
15
|
-
|
|
16
|
-
from .compress import Compressor
|
|
17
|
-
|
|
18
|
-
_PostProcessT = Iterator[tuple[str, str, bool] | tuple[str, None, RuntimeError]]
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
class CompressedStaticFilesStorage(StaticFilesStorage):
|
|
22
|
-
"""
|
|
23
|
-
StaticFilesStorage subclass that compresses output files.
|
|
24
|
-
"""
|
|
25
|
-
|
|
26
|
-
def post_process(
|
|
27
|
-
self, paths: dict[str, Any], dry_run: bool = False, **options: Any
|
|
28
|
-
) -> _PostProcessT:
|
|
29
|
-
if dry_run:
|
|
30
|
-
return
|
|
31
|
-
|
|
32
|
-
extensions = getattr(settings, "WHITENOISE_SKIP_COMPRESS_EXTENSIONS", None)
|
|
33
|
-
compressor = self.create_compressor(extensions=extensions, quiet=True)
|
|
34
|
-
|
|
35
|
-
for path in paths:
|
|
36
|
-
if compressor.should_compress(path):
|
|
37
|
-
full_path = self.path(path)
|
|
38
|
-
prefix_len = len(full_path) - len(path)
|
|
39
|
-
for compressed_path in compressor.compress(full_path):
|
|
40
|
-
compressed_name = compressed_path[prefix_len:]
|
|
41
|
-
yield path, compressed_name, True
|
|
42
|
-
|
|
43
|
-
def create_compressor(self, **kwargs: Any) -> Compressor:
|
|
44
|
-
return Compressor(**kwargs)
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
class MissingFileError(ValueError):
|
|
48
|
-
pass
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
class CompressedManifestStaticFilesStorage(ManifestStaticFilesStorage):
|
|
52
|
-
"""
|
|
53
|
-
Extends ManifestStaticFilesStorage instance to create compressed versions
|
|
54
|
-
of its output files and, optionally, to delete the non-hashed files (i.e.
|
|
55
|
-
those without the hash in their name)
|
|
56
|
-
"""
|
|
57
|
-
|
|
58
|
-
_new_files = None
|
|
59
|
-
|
|
60
|
-
def __init__(self, *args, **kwargs):
|
|
61
|
-
manifest_strict = getattr(settings, "WHITENOISE_MANIFEST_STRICT", None)
|
|
62
|
-
if manifest_strict is not None:
|
|
63
|
-
self.manifest_strict = manifest_strict
|
|
64
|
-
super().__init__(*args, **kwargs)
|
|
65
|
-
|
|
66
|
-
def post_process(self, *args, **kwargs):
|
|
67
|
-
files = super().post_process(*args, **kwargs)
|
|
68
|
-
|
|
69
|
-
if not kwargs.get("dry_run"):
|
|
70
|
-
files = self.post_process_with_compression(files)
|
|
71
|
-
|
|
72
|
-
# Make exception messages helpful
|
|
73
|
-
for name, hashed_name, processed in files:
|
|
74
|
-
if isinstance(processed, Exception):
|
|
75
|
-
processed = self.make_helpful_exception(processed, name)
|
|
76
|
-
yield name, hashed_name, processed
|
|
77
|
-
|
|
78
|
-
def post_process_with_compression(self, files):
|
|
79
|
-
# Files may get hashed multiple times, we want to keep track of all the
|
|
80
|
-
# intermediate files generated during the process and which of these
|
|
81
|
-
# are the final names used for each file. As not every intermediate
|
|
82
|
-
# file is yielded we have to hook in to the `hashed_name` method to
|
|
83
|
-
# keep track of them all.
|
|
84
|
-
hashed_names = {}
|
|
85
|
-
new_files = set()
|
|
86
|
-
self.start_tracking_new_files(new_files)
|
|
87
|
-
for name, hashed_name, processed in files:
|
|
88
|
-
if hashed_name and not isinstance(processed, Exception):
|
|
89
|
-
hashed_names[self.clean_name(name)] = hashed_name
|
|
90
|
-
yield name, hashed_name, processed
|
|
91
|
-
self.stop_tracking_new_files()
|
|
92
|
-
original_files = set(hashed_names.keys())
|
|
93
|
-
hashed_files = set(hashed_names.values())
|
|
94
|
-
if self.keep_only_hashed_files:
|
|
95
|
-
files_to_delete = (original_files | new_files) - hashed_files
|
|
96
|
-
files_to_compress = hashed_files
|
|
97
|
-
else:
|
|
98
|
-
files_to_delete = set()
|
|
99
|
-
files_to_compress = original_files | hashed_files
|
|
100
|
-
self.delete_files(files_to_delete)
|
|
101
|
-
for name, compressed_name in self.compress_files(files_to_compress):
|
|
102
|
-
yield name, compressed_name, True
|
|
103
|
-
|
|
104
|
-
def hashed_name(self, *args, **kwargs):
|
|
105
|
-
name = super().hashed_name(*args, **kwargs)
|
|
106
|
-
if self._new_files is not None:
|
|
107
|
-
self._new_files.add(self.clean_name(name))
|
|
108
|
-
return name
|
|
109
|
-
|
|
110
|
-
def start_tracking_new_files(self, new_files):
|
|
111
|
-
self._new_files = new_files
|
|
112
|
-
|
|
113
|
-
def stop_tracking_new_files(self):
|
|
114
|
-
self._new_files = None
|
|
115
|
-
|
|
116
|
-
@property
|
|
117
|
-
def keep_only_hashed_files(self):
|
|
118
|
-
return getattr(settings, "WHITENOISE_KEEP_ONLY_HASHED_FILES", False)
|
|
119
|
-
|
|
120
|
-
def delete_files(self, files_to_delete):
|
|
121
|
-
for name in files_to_delete:
|
|
122
|
-
try:
|
|
123
|
-
os.unlink(self.path(name))
|
|
124
|
-
except OSError as e:
|
|
125
|
-
if e.errno != errno.ENOENT:
|
|
126
|
-
raise
|
|
127
|
-
|
|
128
|
-
def create_compressor(self, **kwargs):
|
|
129
|
-
return Compressor(**kwargs)
|
|
130
|
-
|
|
131
|
-
def compress_files(self, names):
|
|
132
|
-
extensions = getattr(settings, "WHITENOISE_SKIP_COMPRESS_EXTENSIONS", None)
|
|
133
|
-
compressor = self.create_compressor(extensions=extensions, quiet=True)
|
|
134
|
-
for name in names:
|
|
135
|
-
if compressor.should_compress(name):
|
|
136
|
-
path = self.path(name)
|
|
137
|
-
prefix_len = len(path) - len(name)
|
|
138
|
-
for compressed_path in compressor.compress(path):
|
|
139
|
-
compressed_name = compressed_path[prefix_len:]
|
|
140
|
-
yield name, compressed_name
|
|
141
|
-
|
|
142
|
-
def make_helpful_exception(self, exception, name):
|
|
143
|
-
"""
|
|
144
|
-
If a CSS file contains references to images, fonts etc that can't be found
|
|
145
|
-
then Plain's `post_process` blows up with a not particularly helpful
|
|
146
|
-
ValueError that leads people to think WhiteNoise is broken.
|
|
147
|
-
|
|
148
|
-
Here we attempt to intercept such errors and reformat them to be more
|
|
149
|
-
helpful in revealing the source of the problem.
|
|
150
|
-
"""
|
|
151
|
-
if isinstance(exception, ValueError):
|
|
152
|
-
message = exception.args[0] if len(exception.args) else ""
|
|
153
|
-
# Stringly typed exceptions. Yay!
|
|
154
|
-
match = self._error_msg_re.search(message)
|
|
155
|
-
if match:
|
|
156
|
-
extension = os.path.splitext(name)[1].lstrip(".").upper()
|
|
157
|
-
message = self._error_msg.format(
|
|
158
|
-
orig_message=message,
|
|
159
|
-
filename=name,
|
|
160
|
-
missing=match.group(1),
|
|
161
|
-
ext=extension,
|
|
162
|
-
)
|
|
163
|
-
exception = MissingFileError(message)
|
|
164
|
-
return exception
|
|
165
|
-
|
|
166
|
-
_error_msg_re = re.compile(r"^The file '(.+)' could not be found")
|
|
167
|
-
|
|
168
|
-
_error_msg = textwrap.dedent(
|
|
169
|
-
"""\
|
|
170
|
-
{orig_message}
|
|
171
|
-
|
|
172
|
-
The {ext} file '{filename}' references a file which could not be found:
|
|
173
|
-
{missing}
|
|
174
|
-
|
|
175
|
-
Please check the URL references in this {ext} file, particularly any
|
|
176
|
-
relative paths which might be pointing to the wrong location.
|
|
177
|
-
"""
|
|
178
|
-
)
|
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
# Follow Plain in treating URLs as UTF-8 encoded (which requires undoing the
|
|
5
|
-
# implicit ISO-8859-1 decoding applied in Python 3). Strictly speaking, URLs
|
|
6
|
-
# should only be ASCII anyway, but UTF-8 can be found in the wild.
|
|
7
|
-
def decode_path_info(path_info):
|
|
8
|
-
return path_info.encode("iso-8859-1", "replace").decode("utf-8", "replace")
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
def ensure_leading_trailing_slash(path):
|
|
12
|
-
path = (path or "").strip("/")
|
|
13
|
-
return f"/{path}/" if path else "/"
|
|
File without changes
|