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