v0.150.0
  1from __future__ import annotations
  2
  3from collections.abc import Generator, Iterable
  4from email.utils import formatdate
  5from typing import Any
  6from urllib.parse import quote, unquote
  7from urllib.parse import urlencode as original_urlencode
  8
  9from plain.utils.datastructures import MultiValueDict
 10
 11RFC3986_SUBDELIMS = "!$&'()*+,;="
 12
 13
 14def urlencode(
 15    query: MultiValueDict | dict[str, Any] | Iterable[tuple[str, Any]],
 16    doseq: bool = False,
 17) -> str:
 18    """
 19    A version of Python's urllib.parse.urlencode() function that can operate on
 20    MultiValueDict and non-string values.
 21    """
 22    if isinstance(query, MultiValueDict):
 23        query = query.lists()
 24    elif hasattr(query, "items"):
 25        query = query.items()  # ty: ignore[call-non-callable]
 26    query_params = []
 27    for key, value in query:
 28        if value is None:
 29            raise TypeError(
 30                f"Cannot encode None for key '{key}' in a query string. Did you "
 31                "mean to pass an empty string or omit the value?"
 32            )
 33        elif not doseq or isinstance(value, str | bytes):
 34            query_val = value
 35        else:
 36            try:
 37                itr = iter(value)
 38            except TypeError:
 39                query_val = value
 40            else:
 41                # Consume generators and iterators, when doseq=True, to
 42                # work around https://bugs.python.org/issue31706.
 43                query_val = []
 44                for item in itr:
 45                    if item is None:
 46                        raise TypeError(
 47                            f"Cannot encode None for key '{key}' in a query "
 48                            "string. Did you mean to pass an empty string or "
 49                            "omit the value?"
 50                        )
 51                    elif not isinstance(item, bytes):
 52                        item = str(item)
 53                    query_val.append(item)
 54        query_params.append((key, query_val))
 55    return original_urlencode(query_params, doseq)
 56
 57
 58def http_date(epoch_seconds: float | None = None) -> str:
 59    """
 60    Format the time to match the RFC 5322 date format as specified by RFC 9110
 61    Section 5.6.7.
 62
 63    `epoch_seconds` is a floating point number expressed in seconds since the
 64    epoch, in UTC - such as that outputted by time.time(). If set to None, it
 65    defaults to the current time.
 66
 67    Output a string in the format 'Wdy, DD Mon YYYY HH:MM:SS GMT'.
 68    """
 69    return formatdate(epoch_seconds, usegmt=True)
 70
 71
 72# Base 36 functions: useful for generating compact URLs
 73
 74
 75def base36_to_int(s: str) -> int:
 76    """
 77    Convert a base 36 string to an int. Raise ValueError if the input won't fit
 78    into an int.
 79    """
 80    # To prevent overconsumption of server resources, reject any
 81    # base36 string that is longer than 13 base36 digits (13 digits
 82    # is sufficient to base36-encode any 64-bit integer)
 83    if len(s) > 13:
 84        raise ValueError("Base36 input too large")
 85    return int(s, 36)
 86
 87
 88def int_to_base36(i: int) -> str:
 89    """Convert an integer to a base36 string."""
 90    char_set = "0123456789abcdefghijklmnopqrstuvwxyz"
 91    if i < 0:
 92        raise ValueError("Negative base36 conversion input.")
 93    if i < 36:
 94        return char_set[i]
 95    b36 = ""
 96    while i != 0:
 97        i, n = divmod(i, 36)
 98        b36 = char_set[n] + b36
 99    return b36
100
101
102def escape_leading_slashes(url: str) -> str:
103    """
104    If redirecting to an absolute path (two leading slashes), a slash must be
105    escaped to prevent browsers from handling the path as schemaless and
106    redirecting to another host.
107    """
108    if url.startswith("//"):
109        url = "/%2F{}".format(url.removeprefix("//"))
110    return url
111
112
113def _parseparam(s: str) -> Generator[str]:
114    while s[:1] == ";":
115        s = s[1:]
116        end = s.find(";")
117        while end > 0 and (s.count('"', 0, end) - s.count('\\"', 0, end)) % 2:
118            end = s.find(";", end + 1)
119        if end < 0:
120            end = len(s)
121        f = s[:end]
122        yield f.strip()
123        s = s[end:]
124
125
126def parse_header_parameters(line: str) -> tuple[str, dict[str, str]]:
127    """
128    Parse a Content-type like header.
129    Return the main content-type and a dictionary of options.
130    """
131    parts = _parseparam(";" + line)
132    key = parts.__next__().lower()
133    pdict = {}
134    for p in parts:
135        i = p.find("=")
136        if i >= 0:
137            has_encoding = False
138            name = p[:i].strip().lower()
139            if name.endswith("*"):
140                # Lang/encoding embedded in the value (like "filename*=UTF-8''file.ext")
141                # https://tools.ietf.org/html/rfc2231#section-4
142                name = name[:-1]
143                if p.count("'") == 2:
144                    has_encoding = True
145            value = p[i + 1 :].strip()
146            if len(value) >= 2 and value[0] == value[-1] == '"':
147                value = value[1:-1]
148                value = value.replace("\\\\", "\\").replace('\\"', '"')
149            if has_encoding:
150                encoding, _, value = value.split("'")
151                value = unquote(value, encoding=encoding)
152            pdict[name] = value
153    return key, pdict
154
155
156def content_disposition_header(as_attachment: bool, filename: str) -> str | None:
157    """
158    Construct a Content-Disposition HTTP header value from the given filename
159    as specified by RFC 6266.
160    """
161    if filename:
162        disposition = "attachment" if as_attachment else "inline"
163        try:
164            filename.encode("ascii")
165            file_expr = 'filename="{}"'.format(
166                filename.replace("\\", "\\\\").replace('"', r"\"")
167            )
168        except UnicodeEncodeError:
169            file_expr = f"filename*=utf-8''{quote(filename)}"
170        return f"{disposition}; {file_expr}"
171    elif as_attachment:
172        return "attachment"
173    else:
174        return None