plain 0.24.0__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 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 compile` or configure anything else.
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 compile
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 compile
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 compile
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 compile --keep-original`, though this will typically be combined with `--no-fingerprint` otherwise the fingerprinted files will still get priority in `{{ asset() }}` template calls.
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 compile`
27
+ ### `plain build`
28
28
 
29
29
  Compile static assets (used in the deploy/production process).
30
30
 
31
- Automatically runs `plain tailwind compile` if [plain-tailwind](https://plainframework.com/docs/plain-tailwind/) is installed.
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 compile(keep_original, fingerprint, compress):
280
- """Compile static assets"""
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 compile commands first
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("compile", {})
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 compile steps (like tailwind, typically should run last...)
311
- for entry_point in entry_points(group="plain.assets.compile"):
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
@@ -42,7 +42,6 @@ REASON_INVALID_CHARACTERS = "has invalid characters"
42
42
  CSRF_SECRET_LENGTH = 32
43
43
  CSRF_TOKEN_LENGTH = 2 * CSRF_SECRET_LENGTH
44
44
  CSRF_ALLOWED_CHARS = string.ascii_letters + string.digits
45
- CSRF_SESSION_KEY = "_csrftoken"
46
45
 
47
46
 
48
47
  def _get_new_csrf_string():
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
- silent_variable_failure = True
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, v2 in v:
822
+ for k2, _ in v:
823
823
  if value == k2 or text_value == str(k2):
824
824
  return True
825
825
  else:
@@ -945,9 +945,13 @@ class JSONField(CharField):
945
945
  "invalid": "Enter a valid JSON.",
946
946
  }
947
947
 
948
- def __init__(self, encoder=None, decoder=None, **kwargs):
948
+ def __init__(
949
+ self, encoder=None, decoder=None, indent=None, sort_keys=False, **kwargs
950
+ ):
949
951
  self.encoder = encoder
950
952
  self.decoder = decoder
953
+ self.indent = indent
954
+ self.sort_keys = sort_keys
951
955
  super().__init__(**kwargs)
952
956
 
953
957
  def to_python(self, value):
@@ -983,7 +987,13 @@ class JSONField(CharField):
983
987
  def prepare_value(self, value):
984
988
  if isinstance(value, InvalidJSONInput):
985
989
  return value
986
- return json.dumps(value, ensure_ascii=False, cls=self.encoder)
990
+ return json.dumps(
991
+ value,
992
+ indent=self.indent,
993
+ sort_keys=self.sort_keys,
994
+ ensure_ascii=False,
995
+ cls=self.encoder,
996
+ )
987
997
 
988
998
  def has_changed(self, initial, data):
989
999
  if super().has_changed(initial, data):
plain/forms/forms.py CHANGED
@@ -53,7 +53,6 @@ class BaseForm:
53
53
  class.
54
54
  """
55
55
 
56
- field_order = None
57
56
  prefix = None
58
57
 
59
58
  def __init__(
@@ -399,8 +399,6 @@ class MultiPartParser:
399
399
  return None
400
400
  return file_name
401
401
 
402
- IE_sanitize = sanitize_file_name
403
-
404
402
  def _close_files(self):
405
403
  # Free up all file handles.
406
404
  # FIXME: this currently assumes that upload handlers store the file as 'file'
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 escape_uri_path, iri_to_uri
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
  """
@@ -1,5 +1,5 @@
1
1
  import os
2
- from io import BytesIO, StringIO, UnsupportedOperation
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")
@@ -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
@@ -98,10 +98,3 @@ def get_warning_for_invalid_pattern(pattern):
98
98
  id="urls.E004",
99
99
  )
100
100
  ]
101
-
102
-
103
- def E006(name):
104
- return Error(
105
- f"The {name} setting must end with a slash.",
106
- id="urls.E006",
107
- )
plain/urls/resolvers.py CHANGED
@@ -207,7 +207,7 @@ class URLResolver:
207
207
  else:
208
208
  for name in url_pattern.reverse_dict:
209
209
  for (
210
- matches,
210
+ _,
211
211
  pat,
212
212
  converters,
213
213
  ) in url_pattern.reverse_dict.getlist(name):
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.