plain 0.1.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 (169) hide show
  1. plain/README.md +33 -0
  2. plain/__main__.py +5 -0
  3. plain/assets/README.md +56 -0
  4. plain/assets/__init__.py +6 -0
  5. plain/assets/finders.py +233 -0
  6. plain/assets/preflight.py +14 -0
  7. plain/assets/storage.py +916 -0
  8. plain/assets/utils.py +52 -0
  9. plain/assets/whitenoise/__init__.py +5 -0
  10. plain/assets/whitenoise/base.py +259 -0
  11. plain/assets/whitenoise/compress.py +189 -0
  12. plain/assets/whitenoise/media_types.py +137 -0
  13. plain/assets/whitenoise/middleware.py +197 -0
  14. plain/assets/whitenoise/responders.py +286 -0
  15. plain/assets/whitenoise/storage.py +178 -0
  16. plain/assets/whitenoise/string_utils.py +13 -0
  17. plain/cli/README.md +123 -0
  18. plain/cli/__init__.py +3 -0
  19. plain/cli/cli.py +439 -0
  20. plain/cli/formatting.py +61 -0
  21. plain/cli/packages.py +73 -0
  22. plain/cli/print.py +9 -0
  23. plain/cli/startup.py +33 -0
  24. plain/csrf/README.md +3 -0
  25. plain/csrf/middleware.py +466 -0
  26. plain/csrf/views.py +10 -0
  27. plain/debug.py +23 -0
  28. plain/exceptions.py +242 -0
  29. plain/forms/README.md +14 -0
  30. plain/forms/__init__.py +8 -0
  31. plain/forms/boundfield.py +58 -0
  32. plain/forms/exceptions.py +11 -0
  33. plain/forms/fields.py +1030 -0
  34. plain/forms/forms.py +297 -0
  35. plain/http/README.md +1 -0
  36. plain/http/__init__.py +51 -0
  37. plain/http/cookie.py +20 -0
  38. plain/http/multipartparser.py +743 -0
  39. plain/http/request.py +754 -0
  40. plain/http/response.py +719 -0
  41. plain/internal/__init__.py +0 -0
  42. plain/internal/files/README.md +3 -0
  43. plain/internal/files/__init__.py +3 -0
  44. plain/internal/files/base.py +161 -0
  45. plain/internal/files/locks.py +127 -0
  46. plain/internal/files/move.py +102 -0
  47. plain/internal/files/temp.py +79 -0
  48. plain/internal/files/uploadedfile.py +150 -0
  49. plain/internal/files/uploadhandler.py +254 -0
  50. plain/internal/files/utils.py +78 -0
  51. plain/internal/handlers/__init__.py +0 -0
  52. plain/internal/handlers/base.py +133 -0
  53. plain/internal/handlers/exception.py +145 -0
  54. plain/internal/handlers/wsgi.py +216 -0
  55. plain/internal/legacy/__init__.py +0 -0
  56. plain/internal/legacy/__main__.py +12 -0
  57. plain/internal/legacy/management/__init__.py +414 -0
  58. plain/internal/legacy/management/base.py +692 -0
  59. plain/internal/legacy/management/color.py +113 -0
  60. plain/internal/legacy/management/commands/__init__.py +0 -0
  61. plain/internal/legacy/management/commands/collectstatic.py +297 -0
  62. plain/internal/legacy/management/sql.py +67 -0
  63. plain/internal/legacy/management/utils.py +175 -0
  64. plain/json.py +40 -0
  65. plain/logs/README.md +24 -0
  66. plain/logs/__init__.py +5 -0
  67. plain/logs/configure.py +39 -0
  68. plain/logs/loggers.py +74 -0
  69. plain/logs/utils.py +46 -0
  70. plain/middleware/README.md +3 -0
  71. plain/middleware/__init__.py +0 -0
  72. plain/middleware/clickjacking.py +52 -0
  73. plain/middleware/common.py +87 -0
  74. plain/middleware/gzip.py +64 -0
  75. plain/middleware/security.py +64 -0
  76. plain/packages/README.md +41 -0
  77. plain/packages/__init__.py +4 -0
  78. plain/packages/config.py +259 -0
  79. plain/packages/registry.py +438 -0
  80. plain/paginator.py +187 -0
  81. plain/preflight/README.md +3 -0
  82. plain/preflight/__init__.py +38 -0
  83. plain/preflight/compatibility/__init__.py +0 -0
  84. plain/preflight/compatibility/django_4_0.py +20 -0
  85. plain/preflight/files.py +19 -0
  86. plain/preflight/messages.py +88 -0
  87. plain/preflight/registry.py +72 -0
  88. plain/preflight/security/__init__.py +0 -0
  89. plain/preflight/security/base.py +268 -0
  90. plain/preflight/security/csrf.py +40 -0
  91. plain/preflight/urls.py +117 -0
  92. plain/runtime/README.md +75 -0
  93. plain/runtime/__init__.py +61 -0
  94. plain/runtime/global_settings.py +199 -0
  95. plain/runtime/user_settings.py +353 -0
  96. plain/signals/README.md +14 -0
  97. plain/signals/__init__.py +5 -0
  98. plain/signals/dispatch/__init__.py +9 -0
  99. plain/signals/dispatch/dispatcher.py +320 -0
  100. plain/signals/dispatch/license.txt +35 -0
  101. plain/signing.py +299 -0
  102. plain/templates/README.md +20 -0
  103. plain/templates/__init__.py +6 -0
  104. plain/templates/core.py +24 -0
  105. plain/templates/jinja/README.md +227 -0
  106. plain/templates/jinja/__init__.py +22 -0
  107. plain/templates/jinja/defaults.py +119 -0
  108. plain/templates/jinja/extensions.py +39 -0
  109. plain/templates/jinja/filters.py +28 -0
  110. plain/templates/jinja/globals.py +19 -0
  111. plain/test/README.md +3 -0
  112. plain/test/__init__.py +16 -0
  113. plain/test/client.py +985 -0
  114. plain/test/utils.py +255 -0
  115. plain/urls/README.md +3 -0
  116. plain/urls/__init__.py +40 -0
  117. plain/urls/base.py +118 -0
  118. plain/urls/conf.py +94 -0
  119. plain/urls/converters.py +66 -0
  120. plain/urls/exceptions.py +9 -0
  121. plain/urls/resolvers.py +731 -0
  122. plain/utils/README.md +3 -0
  123. plain/utils/__init__.py +0 -0
  124. plain/utils/_os.py +52 -0
  125. plain/utils/cache.py +327 -0
  126. plain/utils/connection.py +84 -0
  127. plain/utils/crypto.py +76 -0
  128. plain/utils/datastructures.py +345 -0
  129. plain/utils/dateformat.py +329 -0
  130. plain/utils/dateparse.py +154 -0
  131. plain/utils/dates.py +76 -0
  132. plain/utils/deconstruct.py +54 -0
  133. plain/utils/decorators.py +90 -0
  134. plain/utils/deprecation.py +6 -0
  135. plain/utils/duration.py +44 -0
  136. plain/utils/email.py +12 -0
  137. plain/utils/encoding.py +235 -0
  138. plain/utils/functional.py +456 -0
  139. plain/utils/hashable.py +26 -0
  140. plain/utils/html.py +401 -0
  141. plain/utils/http.py +374 -0
  142. plain/utils/inspect.py +73 -0
  143. plain/utils/ipv6.py +46 -0
  144. plain/utils/itercompat.py +8 -0
  145. plain/utils/module_loading.py +69 -0
  146. plain/utils/regex_helper.py +353 -0
  147. plain/utils/safestring.py +72 -0
  148. plain/utils/termcolors.py +221 -0
  149. plain/utils/text.py +518 -0
  150. plain/utils/timesince.py +138 -0
  151. plain/utils/timezone.py +244 -0
  152. plain/utils/tree.py +126 -0
  153. plain/validators.py +603 -0
  154. plain/views/README.md +268 -0
  155. plain/views/__init__.py +18 -0
  156. plain/views/base.py +107 -0
  157. plain/views/csrf.py +24 -0
  158. plain/views/errors.py +25 -0
  159. plain/views/exceptions.py +4 -0
  160. plain/views/forms.py +76 -0
  161. plain/views/objects.py +229 -0
  162. plain/views/redirect.py +72 -0
  163. plain/views/templates.py +66 -0
  164. plain/wsgi.py +11 -0
  165. plain-0.1.0.dist-info/LICENSE +85 -0
  166. plain-0.1.0.dist-info/METADATA +51 -0
  167. plain-0.1.0.dist-info/RECORD +169 -0
  168. plain-0.1.0.dist-info/WHEEL +4 -0
  169. plain-0.1.0.dist-info/entry_points.txt +3 -0
@@ -0,0 +1,197 @@
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
@@ -0,0 +1,286 @@
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
@@ -0,0 +1,178 @@
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
+ )
@@ -0,0 +1,13 @@
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 "/"