v0.165.0
  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"""
 17
 18import time
 19from collections import defaultdict
 20from typing import TYPE_CHECKING, Any
 21
 22from .http import http_date
 23from .regex_helper import _lazy_re_compile
 24
 25if TYPE_CHECKING:
 26    from plain.http import Response
 27
 28_cc_delim_re = _lazy_re_compile(r"\s*,\s*")
 29
 30
 31def patch_response_headers(response: Response, cache_timeout: float) -> None:
 32    """
 33    Add HTTP caching headers to the given HttpResponse: Expires and
 34    Cache-Control.
 35
 36    Each header is only added if it isn't already set.
 37    """
 38    cache_timeout = max(cache_timeout, 0)  # Can't have max-age negative
 39    if "Expires" not in response.headers:
 40        response.headers["Expires"] = http_date(time.time() + cache_timeout)
 41    patch_cache_control(response, max_age=int(cache_timeout))
 42
 43
 44def add_never_cache_headers(response: Response) -> None:
 45    """
 46    Add headers to a response to indicate that a page should never be cached.
 47    """
 48    patch_response_headers(response, cache_timeout=-1)
 49    patch_cache_control(
 50        response, no_cache=True, no_store=True, must_revalidate=True, private=True
 51    )
 52
 53
 54def patch_cache_control(
 55    response: Response,
 56    *,
 57    max_age: int | None = None,
 58    s_maxage: int | None = None,
 59    stale_while_revalidate: int | None = None,
 60    stale_if_error: int | None = None,
 61    no_cache: bool = False,
 62    no_store: bool = False,
 63    no_transform: bool = False,
 64    must_revalidate: bool = False,
 65    proxy_revalidate: bool = False,
 66    must_understand: bool = False,
 67    public: bool = False,
 68    private: bool = False,
 69    immutable: bool = False,
 70) -> None:
 71    """
 72    Patch the Cache-Control header on the response, merging the given
 73    directives with any already present.
 74
 75    Parameter names map to their hyphenated Cache-Control directives
 76    (``max_age`` -> ``max-age``). Integer directives are emitted as
 77    ``name=value`` when set; boolean directives are emitted as a bare
 78    directive name when True. If ``max-age`` is already present, the smaller of
 79    the existing and new value wins (this happens when a decorator and a piece
 80    of middleware both operate on a given view). Setting ``public`` clears an
 81    existing ``private`` directive and vice versa.
 82    """
 83
 84    def dictitem(s: str) -> tuple[str, str | bool]:
 85        t = s.split("=", 1)
 86        if len(t) > 1:
 87            return (t[0].lower(), t[1])
 88        else:
 89            return (t[0].lower(), True)
 90
 91    def dictvalue(directive: str, value: str | bool) -> str:
 92        if value is True:
 93            return directive
 94        else:
 95            return f"{directive}={value}"
 96
 97    # Collect the requested directives under their hyphenated names.
 98    new: dict[str, int | bool] = {}
 99    for directive, number in (
100        ("max-age", max_age),
101        ("s-maxage", s_maxage),
102        ("stale-while-revalidate", stale_while_revalidate),
103        ("stale-if-error", stale_if_error),
104    ):
105        if number is not None:
106            new[directive] = number
107    for directive, flag in (
108        ("no-cache", no_cache),
109        ("no-store", no_store),
110        ("no-transform", no_transform),
111        ("must-revalidate", must_revalidate),
112        ("proxy-revalidate", proxy_revalidate),
113        ("must-understand", must_understand),
114        ("public", public),
115        ("private", private),
116        ("immutable", immutable),
117    ):
118        if flag:
119            new[directive] = True
120
121    cc: defaultdict[str, Any] = defaultdict(set)
122    if response.headers.get("Cache-Control"):
123        for field in _cc_delim_re.split(response.headers["Cache-Control"]):
124            directive, value = dictitem(field)
125            if directive == "no-cache":
126                # no-cache supports multiple field names.
127                cc[directive].add(value)
128            else:
129                cc[directive] = value
130
131    # If there's already a max-age header but we're being asked to set a new
132    # max-age, use the minimum of the two ages.
133    if "max-age" in cc and "max-age" in new:
134        new["max-age"] = min(int(cc["max-age"]), int(new["max-age"]))
135
136    # Allow overriding private caching and vice versa.
137    if "private" in cc and "public" in new:
138        del cc["private"]
139    elif "public" in cc and "private" in new:
140        del cc["public"]
141
142    for directive, value in new.items():
143        if directive == "no-cache":
144            # no-cache supports multiple field names.
145            cc[directive].add(value)
146        else:
147            cc[directive] = value
148
149    directives = []
150    for directive, values in cc.items():
151        if isinstance(values, set):
152            if True in values:
153                # True takes precedence.
154                values = {True}
155            directives.extend([dictvalue(directive, value) for value in values])
156        else:
157            directives.append(dictvalue(directive, values))
158    response.headers["Cache-Control"] = ", ".join(directives)
159
160
161def patch_vary_headers(response: Response, newheaders: list[str]) -> None:
162    """
163    Add (or update) the "Vary" header in the given Response object.
164    newheaders is a list of header names that should be in "Vary". If headers
165    contains an asterisk, then "Vary" header will consist of a single asterisk
166    '*'. Otherwise, existing headers in "Vary" aren't removed.
167    """
168    # Note that we need to keep the original order intact, because cache
169    # implementations may rely on the order of the Vary contents in, say,
170    # computing an MD5 hash.
171    if "Vary" in response.headers:
172        vary_headers = _cc_delim_re.split(response.headers["Vary"])
173    else:
174        vary_headers = []
175    # Use .lower() here so we treat headers as case-insensitive.
176    existing_headers = {header.lower() for header in vary_headers}
177    additional_headers = [
178        newheader
179        for newheader in newheaders
180        if newheader.lower() not in existing_headers
181    ]
182    vary_headers += additional_headers
183    if "*" in vary_headers:
184        response.headers["Vary"] = "*"
185    else:
186        response.headers["Vary"] = ", ".join(vary_headers)