Coverage for /Users/davegaeddert/Development/dropseed/plain/plain/plain/utils/cache.py: 17%

136 statements  

« prev     ^ index     » next       coverage.py v7.6.1, created at 2024-10-16 22:03 -0500

1""" 

2This module contains helper functions for controlling caching. It does so by 

3managing the "Vary" header of responses. It includes functions to patch the 

4header of response objects directly and decorators that change functions to do 

5that header-patching themselves. 

6 

7For information on the Vary header, see RFC 9110 Section 12.5.5. 

8 

9Essentially, the "Vary" HTTP header defines which headers a cache should take 

10into account when building its cache key. Requests with the same path but 

11different header content for headers named in "Vary" need to get different 

12cache keys to prevent delivery of wrong content. 

13 

14An example: i18n middleware would need to distinguish caches by the 

15"Accept-language" header. 

16""" 

17import time 

18from collections import defaultdict 

19from hashlib import md5 

20 

21from plain.http import Response, ResponseNotModified 

22from plain.logs import log_response 

23from plain.runtime import settings 

24from plain.utils.http import http_date, parse_etags, parse_http_date_safe, quote_etag 

25from plain.utils.regex_helper import _lazy_re_compile 

26 

27cc_delim_re = _lazy_re_compile(r"\s*,\s*") 

28 

29 

30def patch_cache_control(response, **kwargs): 

31 """ 

32 Patch the Cache-Control header by adding all keyword arguments to it. 

33 The transformation is as follows: 

34 

35 * All keyword parameter names are turned to lowercase, and underscores 

36 are converted to hyphens. 

37 * If the value of a parameter is True (exactly True, not just a 

38 true value), only the parameter name is added to the header. 

39 * All other parameters are added with their value, after applying 

40 str() to it. 

41 """ 

42 

43 def dictitem(s): 

44 t = s.split("=", 1) 

45 if len(t) > 1: 

46 return (t[0].lower(), t[1]) 

47 else: 

48 return (t[0].lower(), True) 

49 

50 def dictvalue(*t): 

51 if t[1] is True: 

52 return t[0] 

53 else: 

54 return f"{t[0]}={t[1]}" 

55 

56 cc = defaultdict(set) 

57 if response.get("Cache-Control"): 

58 for field in cc_delim_re.split(response.headers["Cache-Control"]): 

59 directive, value = dictitem(field) 

60 if directive == "no-cache": 

61 # no-cache supports multiple field names. 

62 cc[directive].add(value) 

63 else: 

64 cc[directive] = value 

65 

66 # If there's already a max-age header but we're being asked to set a new 

67 # max-age, use the minimum of the two ages. In practice this happens when 

68 # a decorator and a piece of middleware both operate on a given view. 

69 if "max-age" in cc and "max_age" in kwargs: 

70 kwargs["max_age"] = min(int(cc["max-age"]), kwargs["max_age"]) 

71 

72 # Allow overriding private caching and vice versa 

73 if "private" in cc and "public" in kwargs: 

74 del cc["private"] 

75 elif "public" in cc and "private" in kwargs: 

76 del cc["public"] 

77 

78 for k, v in kwargs.items(): 

79 directive = k.replace("_", "-") 

80 if directive == "no-cache": 

81 # no-cache supports multiple field names. 

82 cc[directive].add(v) 

83 else: 

84 cc[directive] = v 

85 

86 directives = [] 

87 for directive, values in cc.items(): 

88 if isinstance(values, set): 

89 if True in values: 

90 # True takes precedence. 

91 values = {True} 

92 directives.extend([dictvalue(directive, value) for value in values]) 

93 else: 

94 directives.append(dictvalue(directive, values)) 

95 cc = ", ".join(directives) 

96 response.headers["Cache-Control"] = cc 

97 

98 

99def get_max_age(response): 

100 """ 

101 Return the max-age from the response Cache-Control header as an integer, 

102 or None if it wasn't found or wasn't an integer. 

103 """ 

104 if not response.has_header("Cache-Control"): 

105 return 

106 cc = dict( 

107 _to_tuple(el) for el in cc_delim_re.split(response.headers["Cache-Control"]) 

108 ) 

109 try: 

110 return int(cc["max-age"]) 

111 except (ValueError, TypeError, KeyError): 

112 pass 

113 

114 

115def set_response_etag(response): 

116 if not response.streaming and response.content: 

117 response.headers["ETag"] = quote_etag( 

118 md5(response.content, usedforsecurity=False).hexdigest(), 

119 ) 

120 return response 

121 

122 

123def _precondition_failed(request): 

124 response = Response(status=412) 

125 log_response( 

126 "Precondition Failed: %s", 

127 request.path, 

128 response=response, 

129 request=request, 

130 ) 

131 return response 

132 

133 

134def _not_modified(request, response=None): 

135 new_response = ResponseNotModified() 

136 if response: 

137 # Preserve the headers required by RFC 9110 Section 15.4.5, as well as 

138 # Last-Modified. 

139 for header in ( 

140 "Cache-Control", 

141 "Content-Location", 

142 "Date", 

143 "ETag", 

144 "Expires", 

145 "Last-Modified", 

146 "Vary", 

147 ): 

148 if header in response: 

149 new_response.headers[header] = response.headers[header] 

150 

151 # Preserve cookies as per the cookie specification: "If a proxy server 

152 # receives a response which contains a Set-cookie header, it should 

153 # propagate the Set-cookie header to the client, regardless of whether 

154 # the response was 304 (Not Modified) or 200 (OK). 

155 # https://curl.haxx.se/rfc/cookie_spec.html 

156 new_response.cookies = response.cookies 

157 return new_response 

158 

159 

160def get_conditional_response(request, etag=None, last_modified=None, response=None): 

161 # Only return conditional responses on successful requests. 

162 if response and not (200 <= response.status_code < 300): 

163 return response 

164 

165 # Get HTTP request headers. 

166 if_match_etags = parse_etags(request.META.get("HTTP_IF_MATCH", "")) 

167 if_unmodified_since = request.META.get("HTTP_IF_UNMODIFIED_SINCE") 

168 if_unmodified_since = if_unmodified_since and parse_http_date_safe( 

169 if_unmodified_since 

170 ) 

171 if_none_match_etags = parse_etags(request.META.get("HTTP_IF_NONE_MATCH", "")) 

172 if_modified_since = request.META.get("HTTP_IF_MODIFIED_SINCE") 

173 if_modified_since = if_modified_since and parse_http_date_safe(if_modified_since) 

174 

175 # Evaluation of request preconditions below follows RFC 9110 Section 

176 # 13.2.2. 

177 # Step 1: Test the If-Match precondition. 

178 if if_match_etags and not _if_match_passes(etag, if_match_etags): 

179 return _precondition_failed(request) 

180 

181 # Step 2: Test the If-Unmodified-Since precondition. 

182 if ( 

183 not if_match_etags 

184 and if_unmodified_since 

185 and not _if_unmodified_since_passes(last_modified, if_unmodified_since) 

186 ): 

187 return _precondition_failed(request) 

188 

189 # Step 3: Test the If-None-Match precondition. 

190 if if_none_match_etags and not _if_none_match_passes(etag, if_none_match_etags): 

191 if request.method in ("GET", "HEAD"): 

192 return _not_modified(request, response) 

193 else: 

194 return _precondition_failed(request) 

195 

196 # Step 4: Test the If-Modified-Since precondition. 

197 if ( 

198 not if_none_match_etags 

199 and if_modified_since 

200 and not _if_modified_since_passes(last_modified, if_modified_since) 

201 and request.method in ("GET", "HEAD") 

202 ): 

203 return _not_modified(request, response) 

204 

205 # Step 5: Test the If-Range precondition (not supported). 

206 # Step 6: Return original response since there isn't a conditional response. 

207 return response 

208 

209 

210def _if_match_passes(target_etag, etags): 

211 """ 

212 Test the If-Match comparison as defined in RFC 9110 Section 13.1.1. 

213 """ 

214 if not target_etag: 

215 # If there isn't an ETag, then there can't be a match. 

216 return False 

217 elif etags == ["*"]: 

218 # The existence of an ETag means that there is "a current 

219 # representation for the target resource", even if the ETag is weak, 

220 # so there is a match to '*'. 

221 return True 

222 elif target_etag.startswith("W/"): 

223 # A weak ETag can never strongly match another ETag. 

224 return False 

225 else: 

226 # Since the ETag is strong, this will only return True if there's a 

227 # strong match. 

228 return target_etag in etags 

229 

230 

231def _if_unmodified_since_passes(last_modified, if_unmodified_since): 

232 """ 

233 Test the If-Unmodified-Since comparison as defined in RFC 9110 Section 

234 13.1.4. 

235 """ 

236 return last_modified and last_modified <= if_unmodified_since 

237 

238 

239def _if_none_match_passes(target_etag, etags): 

240 """ 

241 Test the If-None-Match comparison as defined in RFC 9110 Section 13.1.2. 

242 """ 

243 if not target_etag: 

244 # If there isn't an ETag, then there isn't a match. 

245 return True 

246 elif etags == ["*"]: 

247 # The existence of an ETag means that there is "a current 

248 # representation for the target resource", so there is a match to '*'. 

249 return False 

250 else: 

251 # The comparison should be weak, so look for a match after stripping 

252 # off any weak indicators. 

253 target_etag = target_etag.strip("W/") 

254 etags = (etag.strip("W/") for etag in etags) 

255 return target_etag not in etags 

256 

257 

258def _if_modified_since_passes(last_modified, if_modified_since): 

259 """ 

260 Test the If-Modified-Since comparison as defined in RFC 9110 Section 

261 13.1.3. 

262 """ 

263 return not last_modified or last_modified > if_modified_since 

264 

265 

266def patch_response_headers(response, cache_timeout=None): 

267 """ 

268 Add HTTP caching headers to the given Response: Expires and 

269 Cache-Control. 

270 

271 Each header is only added if it isn't already set. 

272 

273 cache_timeout is in seconds. The CACHE_MIDDLEWARE_SECONDS setting is used 

274 by default. 

275 """ 

276 if cache_timeout is None: 

277 cache_timeout = settings.CACHE_MIDDLEWARE_SECONDS 

278 if cache_timeout < 0: 

279 cache_timeout = 0 # Can't have max-age negative 

280 if not response.has_header("Expires"): 

281 response.headers["Expires"] = http_date(time.time() + cache_timeout) 

282 patch_cache_control(response, max_age=cache_timeout) 

283 

284 

285def add_never_cache_headers(response): 

286 """ 

287 Add headers to a response to indicate that a page should never be cached. 

288 """ 

289 patch_response_headers(response, cache_timeout=-1) 

290 patch_cache_control( 

291 response, no_cache=True, no_store=True, must_revalidate=True, private=True 

292 ) 

293 

294 

295def patch_vary_headers(response, newheaders): 

296 """ 

297 Add (or update) the "Vary" header in the given Response object. 

298 newheaders is a list of header names that should be in "Vary". If headers 

299 contains an asterisk, then "Vary" header will consist of a single asterisk 

300 '*'. Otherwise, existing headers in "Vary" aren't removed. 

301 """ 

302 # Note that we need to keep the original order intact, because cache 

303 # implementations may rely on the order of the Vary contents in, say, 

304 # computing an MD5 hash. 

305 if response.has_header("Vary"): 

306 vary_headers = cc_delim_re.split(response.headers["Vary"]) 

307 else: 

308 vary_headers = [] 

309 # Use .lower() here so we treat headers as case-insensitive. 

310 existing_headers = {header.lower() for header in vary_headers} 

311 additional_headers = [ 

312 newheader 

313 for newheader in newheaders 

314 if newheader.lower() not in existing_headers 

315 ] 

316 vary_headers += additional_headers 

317 if "*" in vary_headers: 

318 response.headers["Vary"] = "*" 

319 else: 

320 response.headers["Vary"] = ", ".join(vary_headers) 

321 

322 

323def _to_tuple(s): 

324 t = s.split("=", 1) 

325 if len(t) == 2: 

326 return t[0].lower(), t[1] 

327 return t[0].lower(), True