plain 0.24.1__py3-none-any.whl → 0.25.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 +5 -5
- plain/cli/README.md +2 -4
- plain/cli/cli.py +6 -6
- plain/csrf/middleware.py +0 -1
- plain/exceptions.py +1 -21
- plain/forms/fields.py +1 -1
- plain/forms/forms.py +0 -1
- plain/http/multipartparser.py +0 -2
- plain/http/request.py +18 -41
- plain/internal/files/base.py +1 -29
- plain/internal/handlers/wsgi.py +18 -1
- plain/paginator.py +0 -4
- plain/preflight/urls.py +0 -7
- plain/urls/resolvers.py +1 -1
- plain/utils/cache.py +0 -202
- plain/utils/encoding.py +0 -105
- plain/utils/functional.py +0 -7
- plain/utils/html.py +1 -276
- plain/utils/http.py +2 -189
- plain/utils/inspect.py +0 -35
- plain/utils/safestring.py +0 -3
- plain/utils/text.py +0 -253
- plain/validators.py +0 -11
- {plain-0.24.1.dist-info → plain-0.25.0.dist-info}/METADATA +1 -1
- {plain-0.24.1.dist-info → plain-0.25.0.dist-info}/RECORD +28 -32
- plain/utils/_os.py +0 -52
- plain/utils/dateformat.py +0 -330
- plain/utils/dates.py +0 -76
- plain/utils/email.py +0 -12
- {plain-0.24.1.dist-info → plain-0.25.0.dist-info}/WHEEL +0 -0
- {plain-0.24.1.dist-info → plain-0.25.0.dist-info}/entry_points.txt +0 -0
- {plain-0.24.1.dist-info → plain-0.25.0.dist-info}/licenses/LICENSE +0 -0
plain/assets/README.md
CHANGED
@@ -30,7 +30,7 @@ Now in your template you can use the `asset()` function to get the URL:
|
|
30
30
|
|
31
31
|
## Local development
|
32
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
|
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 build` or configure anything else.
|
34
34
|
|
35
35
|
|
36
36
|
## Production deployment
|
@@ -38,7 +38,7 @@ When you're working with `settings.DEBUG = True`, the assets will be served dire
|
|
38
38
|
In production, one of your deployment steps should be to compile the assets.
|
39
39
|
|
40
40
|
```bash
|
41
|
-
plain
|
41
|
+
plain build
|
42
42
|
```
|
43
43
|
|
44
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`.
|
@@ -61,7 +61,7 @@ url = get_asset_url("css/style.css")
|
|
61
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
62
|
|
63
63
|
```bash
|
64
|
-
plain
|
64
|
+
plain build
|
65
65
|
mv .plain/assets/compiled /path/to/your/static
|
66
66
|
```
|
67
67
|
|
@@ -70,7 +70,7 @@ mv .plain/assets/compiled /path/to/your/static
|
|
70
70
|
The steps for this will vary, but the general idea is to compile them, and then upload the compiled assets.
|
71
71
|
|
72
72
|
```bash
|
73
|
-
plain
|
73
|
+
plain build
|
74
74
|
./example-upload-to-cdn-script
|
75
75
|
```
|
76
76
|
|
@@ -86,7 +86,7 @@ ASSETS_BASE_URL = "https://cdn.example.com/"
|
|
86
86
|
|
87
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).
|
88
88
|
|
89
|
-
If you need the originals for any reason, you can use `plain
|
89
|
+
If you need the originals for any reason, you can use `plain build --keep-original`, though this will typically be combined with `--no-fingerprint` otherwise the fingerprinted files will still get priority in `{{ asset() }}` template calls.
|
90
90
|
|
91
91
|
|
92
92
|
### What about source maps or imported css files?
|
plain/cli/README.md
CHANGED
@@ -24,13 +24,11 @@ __all__ = [
|
|
24
24
|
]
|
25
25
|
```
|
26
26
|
|
27
|
-
### `plain
|
27
|
+
### `plain build`
|
28
28
|
|
29
29
|
Compile static assets (used in the deploy/production process).
|
30
30
|
|
31
|
-
Automatically runs `plain tailwind
|
32
|
-
|
33
|
-
Automatically runs `npm run compile` if you have a `package.json` with `scripts.compile`.
|
31
|
+
Automatically runs `plain tailwind build` if [plain-tailwind](https://plainframework.com/docs/plain-tailwind/) is installed.
|
34
32
|
|
35
33
|
### `plain run`
|
36
34
|
|
plain/cli/cli.py
CHANGED
@@ -276,8 +276,8 @@ def preflight_checks(package_label, deploy, fail_level, databases):
|
|
276
276
|
default=True,
|
277
277
|
help="Compress the assets",
|
278
278
|
)
|
279
|
-
def
|
280
|
-
"""
|
279
|
+
def build(keep_original, fingerprint, compress):
|
280
|
+
"""Pre-deployment build step (compile assets, css, js, etc.)"""
|
281
281
|
|
282
282
|
if not keep_original and not fingerprint:
|
283
283
|
click.secho(
|
@@ -287,7 +287,7 @@ def compile(keep_original, fingerprint, compress):
|
|
287
287
|
)
|
288
288
|
sys.exit(1)
|
289
289
|
|
290
|
-
# Run user-defined
|
290
|
+
# Run user-defined build commands first
|
291
291
|
pyproject_path = plain.runtime.APP_PATH.parent / "pyproject.toml"
|
292
292
|
if pyproject_path.exists():
|
293
293
|
with pyproject_path.open("rb") as f:
|
@@ -296,7 +296,7 @@ def compile(keep_original, fingerprint, compress):
|
|
296
296
|
for name, data in (
|
297
297
|
pyproject.get("tool", {})
|
298
298
|
.get("plain", {})
|
299
|
-
.get("
|
299
|
+
.get("build", {})
|
300
300
|
.get("run", {})
|
301
301
|
.items()
|
302
302
|
):
|
@@ -307,8 +307,8 @@ def compile(keep_original, fingerprint, compress):
|
|
307
307
|
click.secho(f"Error in {name} (exit {result.returncode})", fg="red")
|
308
308
|
sys.exit(result.returncode)
|
309
309
|
|
310
|
-
# Then run installed package
|
311
|
-
for entry_point in entry_points(group="plain.
|
310
|
+
# Then run installed package build steps (like tailwind, typically should run last...)
|
311
|
+
for entry_point in entry_points(group="plain.build"):
|
312
312
|
click.secho(f"Running {entry_point.name}", bold=True)
|
313
313
|
result = entry_point.load()()
|
314
314
|
print()
|
plain/csrf/middleware.py
CHANGED
plain/exceptions.py
CHANGED
@@ -22,7 +22,7 @@ class PackageRegistryNotReady(Exception):
|
|
22
22
|
class ObjectDoesNotExist(Exception):
|
23
23
|
"""The requested object does not exist"""
|
24
24
|
|
25
|
-
|
25
|
+
pass
|
26
26
|
|
27
27
|
|
28
28
|
class MultipleObjectsReturned(Exception):
|
@@ -86,12 +86,6 @@ class RequestDataTooBig(SuspiciousOperation):
|
|
86
86
|
pass
|
87
87
|
|
88
88
|
|
89
|
-
class RequestAborted(Exception):
|
90
|
-
"""The request was closed before it was completed, or timed out."""
|
91
|
-
|
92
|
-
pass
|
93
|
-
|
94
|
-
|
95
89
|
class BadRequest(Exception):
|
96
90
|
"""The request is malformed and cannot be processed."""
|
97
91
|
|
@@ -104,12 +98,6 @@ class PermissionDenied(Exception):
|
|
104
98
|
pass
|
105
99
|
|
106
100
|
|
107
|
-
class ViewDoesNotExist(Exception):
|
108
|
-
"""The requested view does not exist"""
|
109
|
-
|
110
|
-
pass
|
111
|
-
|
112
|
-
|
113
101
|
class ImproperlyConfigured(Exception):
|
114
102
|
"""Plain is somehow improperly configured"""
|
115
103
|
|
@@ -171,14 +159,6 @@ class ValidationError(Exception):
|
|
171
159
|
self.params = params
|
172
160
|
self.error_list = [self]
|
173
161
|
|
174
|
-
@property
|
175
|
-
def message_dict(self):
|
176
|
-
# Trigger an AttributeError if this ValidationError
|
177
|
-
# doesn't have an error_dict.
|
178
|
-
getattr(self, "error_dict")
|
179
|
-
|
180
|
-
return dict(self)
|
181
|
-
|
182
162
|
@property
|
183
163
|
def messages(self):
|
184
164
|
if hasattr(self, "error_dict"):
|
plain/forms/fields.py
CHANGED
@@ -819,7 +819,7 @@ class ChoiceField(Field):
|
|
819
819
|
for k, v in self.choices:
|
820
820
|
if isinstance(v, list | tuple):
|
821
821
|
# This is an optgroup, so look inside the group for options
|
822
|
-
for k2,
|
822
|
+
for k2, _ in v:
|
823
823
|
if value == k2 or text_value == str(k2):
|
824
824
|
return True
|
825
825
|
else:
|
plain/forms/forms.py
CHANGED
plain/http/multipartparser.py
CHANGED
plain/http/request.py
CHANGED
@@ -5,7 +5,6 @@ from io import BytesIO
|
|
5
5
|
from itertools import chain
|
6
6
|
from urllib.parse import parse_qsl, quote, urlencode, urljoin, urlsplit
|
7
7
|
|
8
|
-
from plain import signing
|
9
8
|
from plain.exceptions import (
|
10
9
|
DisallowedHost,
|
11
10
|
ImproperlyConfigured,
|
@@ -24,12 +23,11 @@ from plain.utils.datastructures import (
|
|
24
23
|
ImmutableList,
|
25
24
|
MultiValueDict,
|
26
25
|
)
|
27
|
-
from plain.utils.encoding import
|
26
|
+
from plain.utils.encoding import iri_to_uri
|
28
27
|
from plain.utils.functional import cached_property
|
29
28
|
from plain.utils.http import is_same_domain, parse_header_parameters
|
30
29
|
from plain.utils.regex_helper import _lazy_re_compile
|
31
30
|
|
32
|
-
RAISE_ERROR = object()
|
33
31
|
host_validation_re = _lazy_re_compile(
|
34
32
|
r"^([a-z0-9.-]+|\[[a-f0-9]*:[a-f0-9\.:]+\])(:[0-9]+)?$"
|
35
33
|
)
|
@@ -177,12 +175,26 @@ class HttpRequest:
|
|
177
175
|
def get_full_path(self, force_append_slash=False):
|
178
176
|
return self._get_full_path(self.path, force_append_slash)
|
179
177
|
|
180
|
-
def get_full_path_info(self, force_append_slash=False):
|
181
|
-
return self._get_full_path(self.path_info, force_append_slash)
|
182
|
-
|
183
178
|
def _get_full_path(self, path, force_append_slash):
|
184
179
|
# RFC 3986 requires query string arguments to be in the ASCII range.
|
185
180
|
# Rather than crash if this doesn't happen, we encode defensively.
|
181
|
+
|
182
|
+
def escape_uri_path(path):
|
183
|
+
"""
|
184
|
+
Escape the unsafe characters from the path portion of a Uniform Resource
|
185
|
+
Identifier (URI).
|
186
|
+
"""
|
187
|
+
# These are the "reserved" and "unreserved" characters specified in RFC
|
188
|
+
# 3986 Sections 2.2 and 2.3:
|
189
|
+
# reserved = ";" | "/" | "?" | ":" | "@" | "&" | "=" | "+" | "$" | ","
|
190
|
+
# unreserved = alphanum | mark
|
191
|
+
# mark = "-" | "_" | "." | "!" | "~" | "*" | "'" | "(" | ")"
|
192
|
+
# The list of safe characters here is constructed subtracting ";", "=",
|
193
|
+
# and "?" according to RFC 3986 Section 3.3.
|
194
|
+
# The reason for not subtracting and escaping "/" is that we are escaping
|
195
|
+
# the entire path, not a path segment.
|
196
|
+
return quote(path, safe="/:@&+$,-_.!~*'()")
|
197
|
+
|
186
198
|
return "{}{}{}".format(
|
187
199
|
escape_uri_path(path),
|
188
200
|
"/" if force_append_slash and not path.endswith("/") else "",
|
@@ -191,30 +203,6 @@ class HttpRequest:
|
|
191
203
|
else "",
|
192
204
|
)
|
193
205
|
|
194
|
-
def get_signed_cookie(self, key, default=RAISE_ERROR, salt="", max_age=None):
|
195
|
-
"""
|
196
|
-
Attempt to return a signed cookie. If the signature fails or the
|
197
|
-
cookie has expired, raise an exception, unless the `default` argument
|
198
|
-
is provided, in which case return that value.
|
199
|
-
"""
|
200
|
-
try:
|
201
|
-
cookie_value = self.COOKIES[key]
|
202
|
-
except KeyError:
|
203
|
-
if default is not RAISE_ERROR:
|
204
|
-
return default
|
205
|
-
else:
|
206
|
-
raise
|
207
|
-
try:
|
208
|
-
value = signing.get_cookie_signer(salt=key + salt).unsign(
|
209
|
-
cookie_value, max_age=max_age
|
210
|
-
)
|
211
|
-
except signing.BadSignature:
|
212
|
-
if default is not RAISE_ERROR:
|
213
|
-
return default
|
214
|
-
else:
|
215
|
-
raise
|
216
|
-
return value
|
217
|
-
|
218
206
|
def build_absolute_uri(self, location=None):
|
219
207
|
"""
|
220
208
|
Build an absolute URI from the location and the variables available in
|
@@ -469,10 +457,6 @@ class HttpHeaders(CaseInsensitiveMapping):
|
|
469
457
|
return header
|
470
458
|
return f"{cls.HTTP_PREFIX}{header}"
|
471
459
|
|
472
|
-
@classmethod
|
473
|
-
def to_asgi_name(cls, header):
|
474
|
-
return header.replace("-", "_").upper()
|
475
|
-
|
476
460
|
@classmethod
|
477
461
|
def to_wsgi_names(cls, headers):
|
478
462
|
return {
|
@@ -480,13 +464,6 @@ class HttpHeaders(CaseInsensitiveMapping):
|
|
480
464
|
for header_name, value in headers.items()
|
481
465
|
}
|
482
466
|
|
483
|
-
@classmethod
|
484
|
-
def to_asgi_names(cls, headers):
|
485
|
-
return {
|
486
|
-
cls.to_asgi_name(header_name): value
|
487
|
-
for header_name, value in headers.items()
|
488
|
-
}
|
489
|
-
|
490
467
|
|
491
468
|
class QueryDict(MultiValueDict):
|
492
469
|
"""
|
plain/internal/files/base.py
CHANGED
@@ -1,5 +1,5 @@
|
|
1
1
|
import os
|
2
|
-
from io import
|
2
|
+
from io import UnsupportedOperation
|
3
3
|
|
4
4
|
from plain.internal.files.utils import FileProxyMixin
|
5
5
|
from plain.utils.functional import cached_property
|
@@ -118,34 +118,6 @@ class File(FileProxyMixin):
|
|
118
118
|
self.file.close()
|
119
119
|
|
120
120
|
|
121
|
-
class ContentFile(File):
|
122
|
-
"""
|
123
|
-
A File-like object that takes just raw content, rather than an actual file.
|
124
|
-
"""
|
125
|
-
|
126
|
-
def __init__(self, content, name=None):
|
127
|
-
stream_class = StringIO if isinstance(content, str) else BytesIO
|
128
|
-
super().__init__(stream_class(content), name=name)
|
129
|
-
self.size = len(content)
|
130
|
-
|
131
|
-
def __str__(self):
|
132
|
-
return "Raw content"
|
133
|
-
|
134
|
-
def __bool__(self):
|
135
|
-
return True
|
136
|
-
|
137
|
-
def open(self, mode=None):
|
138
|
-
self.seek(0)
|
139
|
-
return self
|
140
|
-
|
141
|
-
def close(self):
|
142
|
-
pass
|
143
|
-
|
144
|
-
def write(self, data):
|
145
|
-
self.__dict__.pop("size", None) # Clear the computed size.
|
146
|
-
return self.file.write(data)
|
147
|
-
|
148
|
-
|
149
121
|
def endswith_cr(line):
|
150
122
|
"""Return True if line (a text or bytestring) ends with '\r'."""
|
151
123
|
return line.endswith("\r" if isinstance(line, str) else b"\r")
|
plain/internal/handlers/wsgi.py
CHANGED
@@ -1,10 +1,10 @@
|
|
1
1
|
import uuid
|
2
2
|
from io import IOBase
|
3
|
+
from urllib.parse import quote
|
3
4
|
|
4
5
|
from plain import signals
|
5
6
|
from plain.http import HttpRequest, QueryDict, parse_cookie
|
6
7
|
from plain.internal.handlers import base
|
7
|
-
from plain.utils.encoding import repercent_broken_unicode
|
8
8
|
from plain.utils.functional import cached_property
|
9
9
|
from plain.utils.regex_helper import _lazy_re_compile
|
10
10
|
|
@@ -161,6 +161,23 @@ def get_path_info(environ):
|
|
161
161
|
"""Return the HTTP request's PATH_INFO as a string."""
|
162
162
|
path_info = get_bytes_from_wsgi(environ, "PATH_INFO", "/")
|
163
163
|
|
164
|
+
def repercent_broken_unicode(path):
|
165
|
+
"""
|
166
|
+
As per RFC 3987 Section 3.2, step three of converting a URI into an IRI,
|
167
|
+
repercent-encode any octet produced that is not part of a strictly legal
|
168
|
+
UTF-8 octet sequence.
|
169
|
+
"""
|
170
|
+
while True:
|
171
|
+
try:
|
172
|
+
path.decode()
|
173
|
+
except UnicodeDecodeError as e:
|
174
|
+
# CVE-2019-14235: A recursion shouldn't be used since the exception
|
175
|
+
# handling uses massive amounts of memory
|
176
|
+
repercent = quote(path[e.start : e.end], safe=b"/#%[]=:;$&()+,!?*@'~")
|
177
|
+
path = path[: e.start] + repercent.encode() + path[e.end :]
|
178
|
+
else:
|
179
|
+
return path
|
180
|
+
|
164
181
|
return repercent_broken_unicode(path_info).decode()
|
165
182
|
|
166
183
|
|
plain/paginator.py
CHANGED
@@ -24,10 +24,6 @@ class EmptyPage(InvalidPage):
|
|
24
24
|
|
25
25
|
|
26
26
|
class Paginator:
|
27
|
-
# Translators: String used to replace omitted page numbers in elided page
|
28
|
-
# range generated by paginators, e.g. [1, 2, '…', 5, 6, 7, '…', 9, 10].
|
29
|
-
ELLIPSIS = "…"
|
30
|
-
|
31
27
|
def __init__(self, object_list, per_page, orphans=0, allow_empty_first_page=True):
|
32
28
|
self.object_list = object_list
|
33
29
|
self._check_object_list_is_ordered()
|
plain/preflight/urls.py
CHANGED
plain/urls/resolvers.py
CHANGED
plain/utils/cache.py
CHANGED
@@ -15,14 +15,8 @@ An example: i18n middleware would need to distinguish caches by the
|
|
15
15
|
"Accept-language" header.
|
16
16
|
"""
|
17
17
|
|
18
|
-
import time
|
19
18
|
from collections import defaultdict
|
20
|
-
from hashlib import md5
|
21
19
|
|
22
|
-
from plain.http import Response, ResponseNotModified
|
23
|
-
from plain.logs import log_response
|
24
|
-
from plain.runtime import settings
|
25
|
-
from plain.utils.http import http_date, parse_etags, parse_http_date_safe, quote_etag
|
26
20
|
from plain.utils.regex_helper import _lazy_re_compile
|
27
21
|
|
28
22
|
cc_delim_re = _lazy_re_compile(r"\s*,\s*")
|
@@ -97,202 +91,6 @@ def patch_cache_control(response, **kwargs):
|
|
97
91
|
response.headers["Cache-Control"] = cc
|
98
92
|
|
99
93
|
|
100
|
-
def get_max_age(response):
|
101
|
-
"""
|
102
|
-
Return the max-age from the response Cache-Control header as an integer,
|
103
|
-
or None if it wasn't found or wasn't an integer.
|
104
|
-
"""
|
105
|
-
if "Cache-Control" not in response.headers:
|
106
|
-
return
|
107
|
-
cc = dict(
|
108
|
-
_to_tuple(el) for el in cc_delim_re.split(response.headers["Cache-Control"])
|
109
|
-
)
|
110
|
-
try:
|
111
|
-
return int(cc["max-age"])
|
112
|
-
except (ValueError, TypeError, KeyError):
|
113
|
-
pass
|
114
|
-
|
115
|
-
|
116
|
-
def set_response_etag(response):
|
117
|
-
if not response.streaming and response.content:
|
118
|
-
response.headers["ETag"] = quote_etag(
|
119
|
-
md5(response.content, usedforsecurity=False).hexdigest(),
|
120
|
-
)
|
121
|
-
return response
|
122
|
-
|
123
|
-
|
124
|
-
def _precondition_failed(request):
|
125
|
-
response = Response(status=412)
|
126
|
-
log_response(
|
127
|
-
"Precondition Failed: %s",
|
128
|
-
request.path,
|
129
|
-
response=response,
|
130
|
-
request=request,
|
131
|
-
)
|
132
|
-
return response
|
133
|
-
|
134
|
-
|
135
|
-
def _not_modified(request, response=None):
|
136
|
-
new_response = ResponseNotModified()
|
137
|
-
if response:
|
138
|
-
# Preserve the headers required by RFC 9110 Section 15.4.5, as well as
|
139
|
-
# Last-Modified.
|
140
|
-
for header in (
|
141
|
-
"Cache-Control",
|
142
|
-
"Content-Location",
|
143
|
-
"Date",
|
144
|
-
"ETag",
|
145
|
-
"Expires",
|
146
|
-
"Last-Modified",
|
147
|
-
"Vary",
|
148
|
-
):
|
149
|
-
if header in response.headers:
|
150
|
-
new_response.headers[header] = response.headers[header]
|
151
|
-
|
152
|
-
# Preserve cookies as per the cookie specification: "If a proxy server
|
153
|
-
# receives a response which contains a Set-cookie header, it should
|
154
|
-
# propagate the Set-cookie header to the client, regardless of whether
|
155
|
-
# the response was 304 (Not Modified) or 200 (OK).
|
156
|
-
# https://curl.haxx.se/rfc/cookie_spec.html
|
157
|
-
new_response.cookies = response.cookies
|
158
|
-
return new_response
|
159
|
-
|
160
|
-
|
161
|
-
def get_conditional_response(request, etag=None, last_modified=None, response=None):
|
162
|
-
# Only return conditional responses on successful requests.
|
163
|
-
if response and not (200 <= response.status_code < 300):
|
164
|
-
return response
|
165
|
-
|
166
|
-
# Get HTTP request headers.
|
167
|
-
if_match_etags = parse_etags(request.META.get("HTTP_IF_MATCH", ""))
|
168
|
-
if_unmodified_since = request.META.get("HTTP_IF_UNMODIFIED_SINCE")
|
169
|
-
if_unmodified_since = if_unmodified_since and parse_http_date_safe(
|
170
|
-
if_unmodified_since
|
171
|
-
)
|
172
|
-
if_none_match_etags = parse_etags(request.META.get("HTTP_IF_NONE_MATCH", ""))
|
173
|
-
if_modified_since = request.META.get("HTTP_IF_MODIFIED_SINCE")
|
174
|
-
if_modified_since = if_modified_since and parse_http_date_safe(if_modified_since)
|
175
|
-
|
176
|
-
# Evaluation of request preconditions below follows RFC 9110 Section
|
177
|
-
# 13.2.2.
|
178
|
-
# Step 1: Test the If-Match precondition.
|
179
|
-
if if_match_etags and not _if_match_passes(etag, if_match_etags):
|
180
|
-
return _precondition_failed(request)
|
181
|
-
|
182
|
-
# Step 2: Test the If-Unmodified-Since precondition.
|
183
|
-
if (
|
184
|
-
not if_match_etags
|
185
|
-
and if_unmodified_since
|
186
|
-
and not _if_unmodified_since_passes(last_modified, if_unmodified_since)
|
187
|
-
):
|
188
|
-
return _precondition_failed(request)
|
189
|
-
|
190
|
-
# Step 3: Test the If-None-Match precondition.
|
191
|
-
if if_none_match_etags and not _if_none_match_passes(etag, if_none_match_etags):
|
192
|
-
if request.method in ("GET", "HEAD"):
|
193
|
-
return _not_modified(request, response)
|
194
|
-
else:
|
195
|
-
return _precondition_failed(request)
|
196
|
-
|
197
|
-
# Step 4: Test the If-Modified-Since precondition.
|
198
|
-
if (
|
199
|
-
not if_none_match_etags
|
200
|
-
and if_modified_since
|
201
|
-
and not _if_modified_since_passes(last_modified, if_modified_since)
|
202
|
-
and request.method in ("GET", "HEAD")
|
203
|
-
):
|
204
|
-
return _not_modified(request, response)
|
205
|
-
|
206
|
-
# Step 5: Test the If-Range precondition (not supported).
|
207
|
-
# Step 6: Return original response since there isn't a conditional response.
|
208
|
-
return response
|
209
|
-
|
210
|
-
|
211
|
-
def _if_match_passes(target_etag, etags):
|
212
|
-
"""
|
213
|
-
Test the If-Match comparison as defined in RFC 9110 Section 13.1.1.
|
214
|
-
"""
|
215
|
-
if not target_etag:
|
216
|
-
# If there isn't an ETag, then there can't be a match.
|
217
|
-
return False
|
218
|
-
elif etags == ["*"]:
|
219
|
-
# The existence of an ETag means that there is "a current
|
220
|
-
# representation for the target resource", even if the ETag is weak,
|
221
|
-
# so there is a match to '*'.
|
222
|
-
return True
|
223
|
-
elif target_etag.startswith("W/"):
|
224
|
-
# A weak ETag can never strongly match another ETag.
|
225
|
-
return False
|
226
|
-
else:
|
227
|
-
# Since the ETag is strong, this will only return True if there's a
|
228
|
-
# strong match.
|
229
|
-
return target_etag in etags
|
230
|
-
|
231
|
-
|
232
|
-
def _if_unmodified_since_passes(last_modified, if_unmodified_since):
|
233
|
-
"""
|
234
|
-
Test the If-Unmodified-Since comparison as defined in RFC 9110 Section
|
235
|
-
13.1.4.
|
236
|
-
"""
|
237
|
-
return last_modified and last_modified <= if_unmodified_since
|
238
|
-
|
239
|
-
|
240
|
-
def _if_none_match_passes(target_etag, etags):
|
241
|
-
"""
|
242
|
-
Test the If-None-Match comparison as defined in RFC 9110 Section 13.1.2.
|
243
|
-
"""
|
244
|
-
if not target_etag:
|
245
|
-
# If there isn't an ETag, then there isn't a match.
|
246
|
-
return True
|
247
|
-
elif etags == ["*"]:
|
248
|
-
# The existence of an ETag means that there is "a current
|
249
|
-
# representation for the target resource", so there is a match to '*'.
|
250
|
-
return False
|
251
|
-
else:
|
252
|
-
# The comparison should be weak, so look for a match after stripping
|
253
|
-
# off any weak indicators.
|
254
|
-
target_etag = target_etag.strip("W/")
|
255
|
-
etags = (etag.strip("W/") for etag in etags)
|
256
|
-
return target_etag not in etags
|
257
|
-
|
258
|
-
|
259
|
-
def _if_modified_since_passes(last_modified, if_modified_since):
|
260
|
-
"""
|
261
|
-
Test the If-Modified-Since comparison as defined in RFC 9110 Section
|
262
|
-
13.1.3.
|
263
|
-
"""
|
264
|
-
return not last_modified or last_modified > if_modified_since
|
265
|
-
|
266
|
-
|
267
|
-
def patch_response_headers(response, cache_timeout=None):
|
268
|
-
"""
|
269
|
-
Add HTTP caching headers to the given Response: Expires and
|
270
|
-
Cache-Control.
|
271
|
-
|
272
|
-
Each header is only added if it isn't already set.
|
273
|
-
|
274
|
-
cache_timeout is in seconds. The CACHE_MIDDLEWARE_SECONDS setting is used
|
275
|
-
by default.
|
276
|
-
"""
|
277
|
-
if cache_timeout is None:
|
278
|
-
cache_timeout = settings.CACHE_MIDDLEWARE_SECONDS
|
279
|
-
if cache_timeout < 0:
|
280
|
-
cache_timeout = 0 # Can't have max-age negative
|
281
|
-
if "Expires" not in response.headers:
|
282
|
-
response.headers["Expires"] = http_date(time.time() + cache_timeout)
|
283
|
-
patch_cache_control(response, max_age=cache_timeout)
|
284
|
-
|
285
|
-
|
286
|
-
def add_never_cache_headers(response):
|
287
|
-
"""
|
288
|
-
Add headers to a response to indicate that a page should never be cached.
|
289
|
-
"""
|
290
|
-
patch_response_headers(response, cache_timeout=-1)
|
291
|
-
patch_cache_control(
|
292
|
-
response, no_cache=True, no_store=True, must_revalidate=True, private=True
|
293
|
-
)
|
294
|
-
|
295
|
-
|
296
94
|
def patch_vary_headers(response, newheaders):
|
297
95
|
"""
|
298
96
|
Add (or update) the "Vary" header in the given Response object.
|