1406 lines
		
	
	
	
		
			44 KiB
		
	
	
	
		
			Python
		
	
	
	
	
	
		
		
			
		
	
	
			1406 lines
		
	
	
	
		
			44 KiB
		
	
	
	
		
			Python
		
	
	
	
	
	
|   | from __future__ import annotations | ||
|  | 
 | ||
|  | import email.utils | ||
|  | import re | ||
|  | import typing as t | ||
|  | import warnings | ||
|  | from datetime import date | ||
|  | from datetime import datetime | ||
|  | from datetime import time | ||
|  | from datetime import timedelta | ||
|  | from datetime import timezone | ||
|  | from enum import Enum | ||
|  | from hashlib import sha1 | ||
|  | from time import mktime | ||
|  | from time import struct_time | ||
|  | from urllib.parse import quote | ||
|  | from urllib.parse import unquote | ||
|  | from urllib.request import parse_http_list as _parse_list_header | ||
|  | 
 | ||
|  | from ._internal import _dt_as_utc | ||
|  | from ._internal import _plain_int | ||
|  | 
 | ||
|  | if t.TYPE_CHECKING: | ||
|  |     from _typeshed.wsgi import WSGIEnvironment | ||
|  | 
 | ||
|  | _token_chars = frozenset( | ||
|  |     "!#$%&'*+-.0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ^_`abcdefghijklmnopqrstuvwxyz|~" | ||
|  | ) | ||
|  | _etag_re = re.compile(r'([Ww]/)?(?:"(.*?)"|(.*?))(?:\s*,\s*|$)') | ||
|  | _entity_headers = frozenset( | ||
|  |     [ | ||
|  |         "allow", | ||
|  |         "content-encoding", | ||
|  |         "content-language", | ||
|  |         "content-length", | ||
|  |         "content-location", | ||
|  |         "content-md5", | ||
|  |         "content-range", | ||
|  |         "content-type", | ||
|  |         "expires", | ||
|  |         "last-modified", | ||
|  |     ] | ||
|  | ) | ||
|  | _hop_by_hop_headers = frozenset( | ||
|  |     [ | ||
|  |         "connection", | ||
|  |         "keep-alive", | ||
|  |         "proxy-authenticate", | ||
|  |         "proxy-authorization", | ||
|  |         "te", | ||
|  |         "trailer", | ||
|  |         "transfer-encoding", | ||
|  |         "upgrade", | ||
|  |     ] | ||
|  | ) | ||
|  | HTTP_STATUS_CODES = { | ||
|  |     100: "Continue", | ||
|  |     101: "Switching Protocols", | ||
|  |     102: "Processing", | ||
|  |     103: "Early Hints",  # see RFC 8297 | ||
|  |     200: "OK", | ||
|  |     201: "Created", | ||
|  |     202: "Accepted", | ||
|  |     203: "Non Authoritative Information", | ||
|  |     204: "No Content", | ||
|  |     205: "Reset Content", | ||
|  |     206: "Partial Content", | ||
|  |     207: "Multi Status", | ||
|  |     208: "Already Reported",  # see RFC 5842 | ||
|  |     226: "IM Used",  # see RFC 3229 | ||
|  |     300: "Multiple Choices", | ||
|  |     301: "Moved Permanently", | ||
|  |     302: "Found", | ||
|  |     303: "See Other", | ||
|  |     304: "Not Modified", | ||
|  |     305: "Use Proxy", | ||
|  |     306: "Switch Proxy",  # unused | ||
|  |     307: "Temporary Redirect", | ||
|  |     308: "Permanent Redirect", | ||
|  |     400: "Bad Request", | ||
|  |     401: "Unauthorized", | ||
|  |     402: "Payment Required",  # unused | ||
|  |     403: "Forbidden", | ||
|  |     404: "Not Found", | ||
|  |     405: "Method Not Allowed", | ||
|  |     406: "Not Acceptable", | ||
|  |     407: "Proxy Authentication Required", | ||
|  |     408: "Request Timeout", | ||
|  |     409: "Conflict", | ||
|  |     410: "Gone", | ||
|  |     411: "Length Required", | ||
|  |     412: "Precondition Failed", | ||
|  |     413: "Request Entity Too Large", | ||
|  |     414: "Request URI Too Long", | ||
|  |     415: "Unsupported Media Type", | ||
|  |     416: "Requested Range Not Satisfiable", | ||
|  |     417: "Expectation Failed", | ||
|  |     418: "I'm a teapot",  # see RFC 2324 | ||
|  |     421: "Misdirected Request",  # see RFC 7540 | ||
|  |     422: "Unprocessable Entity", | ||
|  |     423: "Locked", | ||
|  |     424: "Failed Dependency", | ||
|  |     425: "Too Early",  # see RFC 8470 | ||
|  |     426: "Upgrade Required", | ||
|  |     428: "Precondition Required",  # see RFC 6585 | ||
|  |     429: "Too Many Requests", | ||
|  |     431: "Request Header Fields Too Large", | ||
|  |     449: "Retry With",  # proprietary MS extension | ||
|  |     451: "Unavailable For Legal Reasons", | ||
|  |     500: "Internal Server Error", | ||
|  |     501: "Not Implemented", | ||
|  |     502: "Bad Gateway", | ||
|  |     503: "Service Unavailable", | ||
|  |     504: "Gateway Timeout", | ||
|  |     505: "HTTP Version Not Supported", | ||
|  |     506: "Variant Also Negotiates",  # see RFC 2295 | ||
|  |     507: "Insufficient Storage", | ||
|  |     508: "Loop Detected",  # see RFC 5842 | ||
|  |     510: "Not Extended", | ||
|  |     511: "Network Authentication Failed", | ||
|  | } | ||
|  | 
 | ||
|  | 
 | ||
|  | class COEP(Enum): | ||
|  |     """Cross Origin Embedder Policies""" | ||
|  | 
 | ||
|  |     UNSAFE_NONE = "unsafe-none" | ||
|  |     REQUIRE_CORP = "require-corp" | ||
|  | 
 | ||
|  | 
 | ||
|  | class COOP(Enum): | ||
|  |     """Cross Origin Opener Policies""" | ||
|  | 
 | ||
|  |     UNSAFE_NONE = "unsafe-none" | ||
|  |     SAME_ORIGIN_ALLOW_POPUPS = "same-origin-allow-popups" | ||
|  |     SAME_ORIGIN = "same-origin" | ||
|  | 
 | ||
|  | 
 | ||
|  | def quote_header_value(value: t.Any, allow_token: bool = True) -> str: | ||
|  |     """Add double quotes around a header value. If the header contains only ASCII token
 | ||
|  |     characters, it will be returned unchanged. If the header contains ``"`` or ``\\`` | ||
|  |     characters, they will be escaped with an additional ``\\`` character. | ||
|  | 
 | ||
|  |     This is the reverse of :func:`unquote_header_value`. | ||
|  | 
 | ||
|  |     :param value: The value to quote. Will be converted to a string. | ||
|  |     :param allow_token: Disable to quote the value even if it only has token characters. | ||
|  | 
 | ||
|  |     .. versionchanged:: 3.0 | ||
|  |         Passing bytes is not supported. | ||
|  | 
 | ||
|  |     .. versionchanged:: 3.0 | ||
|  |         The ``extra_chars`` parameter is removed. | ||
|  | 
 | ||
|  |     .. versionchanged:: 2.3 | ||
|  |         The value is quoted if it is the empty string. | ||
|  | 
 | ||
|  |     .. versionadded:: 0.5 | ||
|  |     """
 | ||
|  |     value_str = str(value) | ||
|  | 
 | ||
|  |     if not value_str: | ||
|  |         return '""' | ||
|  | 
 | ||
|  |     if allow_token: | ||
|  |         token_chars = _token_chars | ||
|  | 
 | ||
|  |         if token_chars.issuperset(value_str): | ||
|  |             return value_str | ||
|  | 
 | ||
|  |     value_str = value_str.replace("\\", "\\\\").replace('"', '\\"') | ||
|  |     return f'"{value_str}"' | ||
|  | 
 | ||
|  | 
 | ||
|  | def unquote_header_value(value: str) -> str: | ||
|  |     """Remove double quotes and decode slash-escaped ``"`` and ``\\`` characters in a
 | ||
|  |     header value. | ||
|  | 
 | ||
|  |     This is the reverse of :func:`quote_header_value`. | ||
|  | 
 | ||
|  |     :param value: The header value to unquote. | ||
|  | 
 | ||
|  |     .. versionchanged:: 3.0 | ||
|  |         The ``is_filename`` parameter is removed. | ||
|  |     """
 | ||
|  |     if len(value) >= 2 and value[0] == value[-1] == '"': | ||
|  |         value = value[1:-1] | ||
|  |         return value.replace("\\\\", "\\").replace('\\"', '"') | ||
|  | 
 | ||
|  |     return value | ||
|  | 
 | ||
|  | 
 | ||
|  | def dump_options_header(header: str | None, options: t.Mapping[str, t.Any]) -> str: | ||
|  |     """Produce a header value and ``key=value`` parameters separated by semicolons
 | ||
|  |     ``;``. For example, the ``Content-Type`` header. | ||
|  | 
 | ||
|  |     .. code-block:: python | ||
|  | 
 | ||
|  |         dump_options_header("text/html", {"charset": "UTF-8"}) | ||
|  |         'text/html; charset=UTF-8' | ||
|  | 
 | ||
|  |     This is the reverse of :func:`parse_options_header`. | ||
|  | 
 | ||
|  |     If a value contains non-token characters, it will be quoted. | ||
|  | 
 | ||
|  |     If a value is ``None``, the parameter is skipped. | ||
|  | 
 | ||
|  |     In some keys for some headers, a UTF-8 value can be encoded using a special | ||
|  |     ``key*=UTF-8''value`` form, where ``value`` is percent encoded. This function will | ||
|  |     not produce that format automatically, but if a given key ends with an asterisk | ||
|  |     ``*``, the value is assumed to have that form and will not be quoted further. | ||
|  | 
 | ||
|  |     :param header: The primary header value. | ||
|  |     :param options: Parameters to encode as ``key=value`` pairs. | ||
|  | 
 | ||
|  |     .. versionchanged:: 2.3 | ||
|  |         Keys with ``None`` values are skipped rather than treated as a bare key. | ||
|  | 
 | ||
|  |     .. versionchanged:: 2.2.3 | ||
|  |         If a key ends with ``*``, its value will not be quoted. | ||
|  |     """
 | ||
|  |     segments = [] | ||
|  | 
 | ||
|  |     if header is not None: | ||
|  |         segments.append(header) | ||
|  | 
 | ||
|  |     for key, value in options.items(): | ||
|  |         if value is None: | ||
|  |             continue | ||
|  | 
 | ||
|  |         if key[-1] == "*": | ||
|  |             segments.append(f"{key}={value}") | ||
|  |         else: | ||
|  |             segments.append(f"{key}={quote_header_value(value)}") | ||
|  | 
 | ||
|  |     return "; ".join(segments) | ||
|  | 
 | ||
|  | 
 | ||
|  | def dump_header(iterable: dict[str, t.Any] | t.Iterable[t.Any]) -> str: | ||
|  |     """Produce a header value from a list of items or ``key=value`` pairs, separated by
 | ||
|  |     commas ``,``. | ||
|  | 
 | ||
|  |     This is the reverse of :func:`parse_list_header`, :func:`parse_dict_header`, and | ||
|  |     :func:`parse_set_header`. | ||
|  | 
 | ||
|  |     If a value contains non-token characters, it will be quoted. | ||
|  | 
 | ||
|  |     If a value is ``None``, the key is output alone. | ||
|  | 
 | ||
|  |     In some keys for some headers, a UTF-8 value can be encoded using a special | ||
|  |     ``key*=UTF-8''value`` form, where ``value`` is percent encoded. This function will | ||
|  |     not produce that format automatically, but if a given key ends with an asterisk | ||
|  |     ``*``, the value is assumed to have that form and will not be quoted further. | ||
|  | 
 | ||
|  |     .. code-block:: python | ||
|  | 
 | ||
|  |         dump_header(["foo", "bar baz"]) | ||
|  |         'foo, "bar baz"' | ||
|  | 
 | ||
|  |         dump_header({"foo": "bar baz"}) | ||
|  |         'foo="bar baz"' | ||
|  | 
 | ||
|  |     :param iterable: The items to create a header from. | ||
|  | 
 | ||
|  |     .. versionchanged:: 3.0 | ||
|  |         The ``allow_token`` parameter is removed. | ||
|  | 
 | ||
|  |     .. versionchanged:: 2.2.3 | ||
|  |         If a key ends with ``*``, its value will not be quoted. | ||
|  |     """
 | ||
|  |     if isinstance(iterable, dict): | ||
|  |         items = [] | ||
|  | 
 | ||
|  |         for key, value in iterable.items(): | ||
|  |             if value is None: | ||
|  |                 items.append(key) | ||
|  |             elif key[-1] == "*": | ||
|  |                 items.append(f"{key}={value}") | ||
|  |             else: | ||
|  |                 items.append(f"{key}={quote_header_value(value)}") | ||
|  |     else: | ||
|  |         items = [quote_header_value(x) for x in iterable] | ||
|  | 
 | ||
|  |     return ", ".join(items) | ||
|  | 
 | ||
|  | 
 | ||
|  | def dump_csp_header(header: ds.ContentSecurityPolicy) -> str: | ||
|  |     """Dump a Content Security Policy header.
 | ||
|  | 
 | ||
|  |     These are structured into policies such as "default-src 'self'; | ||
|  |     script-src 'self'". | ||
|  | 
 | ||
|  |     .. versionadded:: 1.0.0 | ||
|  |        Support for Content Security Policy headers was added. | ||
|  | 
 | ||
|  |     """
 | ||
|  |     return "; ".join(f"{key} {value}" for key, value in header.items()) | ||
|  | 
 | ||
|  | 
 | ||
|  | def parse_list_header(value: str) -> list[str]: | ||
|  |     """Parse a header value that consists of a list of comma separated items according
 | ||
|  |     to `RFC 9110 <https://httpwg.org/specs/rfc9110.html#abnf.extension>`__. | ||
|  | 
 | ||
|  |     This extends :func:`urllib.request.parse_http_list` to remove surrounding quotes | ||
|  |     from values. | ||
|  | 
 | ||
|  |     .. code-block:: python | ||
|  | 
 | ||
|  |         parse_list_header('token, "quoted value"') | ||
|  |         ['token', 'quoted value'] | ||
|  | 
 | ||
|  |     This is the reverse of :func:`dump_header`. | ||
|  | 
 | ||
|  |     :param value: The header value to parse. | ||
|  |     """
 | ||
|  |     result = [] | ||
|  | 
 | ||
|  |     for item in _parse_list_header(value): | ||
|  |         if len(item) >= 2 and item[0] == item[-1] == '"': | ||
|  |             item = item[1:-1] | ||
|  | 
 | ||
|  |         result.append(item) | ||
|  | 
 | ||
|  |     return result | ||
|  | 
 | ||
|  | 
 | ||
|  | def parse_dict_header(value: str) -> dict[str, str | None]: | ||
|  |     """Parse a list header using :func:`parse_list_header`, then parse each item as a
 | ||
|  |     ``key=value`` pair. | ||
|  | 
 | ||
|  |     .. code-block:: python | ||
|  | 
 | ||
|  |         parse_dict_header('a=b, c="d, e", f') | ||
|  |         {"a": "b", "c": "d, e", "f": None} | ||
|  | 
 | ||
|  |     This is the reverse of :func:`dump_header`. | ||
|  | 
 | ||
|  |     If a key does not have a value, it is ``None``. | ||
|  | 
 | ||
|  |     This handles charsets for values as described in | ||
|  |     `RFC 2231 <https://www.rfc-editor.org/rfc/rfc2231#section-3>`__. Only ASCII, UTF-8, | ||
|  |     and ISO-8859-1 charsets are accepted, otherwise the value remains quoted. | ||
|  | 
 | ||
|  |     :param value: The header value to parse. | ||
|  | 
 | ||
|  |     .. versionchanged:: 3.0 | ||
|  |         Passing bytes is not supported. | ||
|  | 
 | ||
|  |     .. versionchanged:: 3.0 | ||
|  |         The ``cls`` argument is removed. | ||
|  | 
 | ||
|  |     .. versionchanged:: 2.3 | ||
|  |         Added support for ``key*=charset''value`` encoded items. | ||
|  | 
 | ||
|  |     .. versionchanged:: 0.9 | ||
|  |        The ``cls`` argument was added. | ||
|  |     """
 | ||
|  |     result: dict[str, str | None] = {} | ||
|  | 
 | ||
|  |     for item in parse_list_header(value): | ||
|  |         key, has_value, value = item.partition("=") | ||
|  |         key = key.strip() | ||
|  | 
 | ||
|  |         if not key: | ||
|  |             # =value is not valid | ||
|  |             continue | ||
|  | 
 | ||
|  |         if not has_value: | ||
|  |             result[key] = None | ||
|  |             continue | ||
|  | 
 | ||
|  |         value = value.strip() | ||
|  |         encoding: str | None = None | ||
|  | 
 | ||
|  |         if key[-1] == "*": | ||
|  |             # key*=charset''value becomes key=value, where value is percent encoded | ||
|  |             # adapted from parse_options_header, without the continuation handling | ||
|  |             key = key[:-1] | ||
|  |             match = _charset_value_re.match(value) | ||
|  | 
 | ||
|  |             if match: | ||
|  |                 # If there is a charset marker in the value, split it off. | ||
|  |                 encoding, value = match.groups() | ||
|  |                 encoding = encoding.lower() | ||
|  | 
 | ||
|  |             # A safe list of encodings. Modern clients should only send ASCII or UTF-8. | ||
|  |             # This list will not be extended further. An invalid encoding will leave the | ||
|  |             # value quoted. | ||
|  |             if encoding in {"ascii", "us-ascii", "utf-8", "iso-8859-1"}: | ||
|  |                 # invalid bytes are replaced during unquoting | ||
|  |                 value = unquote(value, encoding=encoding) | ||
|  | 
 | ||
|  |         if len(value) >= 2 and value[0] == value[-1] == '"': | ||
|  |             value = value[1:-1] | ||
|  | 
 | ||
|  |         result[key] = value | ||
|  | 
 | ||
|  |     return result | ||
|  | 
 | ||
|  | 
 | ||
|  | # https://httpwg.org/specs/rfc9110.html#parameter | ||
|  | _parameter_key_re = re.compile(r"([\w!#$%&'*+\-.^`|~]+)=", flags=re.ASCII) | ||
|  | _parameter_token_value_re = re.compile(r"[\w!#$%&'*+\-.^`|~]+", flags=re.ASCII) | ||
|  | # https://www.rfc-editor.org/rfc/rfc2231#section-4 | ||
|  | _charset_value_re = re.compile( | ||
|  |     r"""
 | ||
|  |     ([\w!#$%&*+\-.^`|~]*)'  # charset part, could be empty | ||
|  |     [\w!#$%&*+\-.^`|~]*'  # don't care about language part, usually empty | ||
|  |     ([\w!#$%&'*+\-.^`|~]+)  # one or more token chars with percent encoding | ||
|  |     """,
 | ||
|  |     re.ASCII | re.VERBOSE, | ||
|  | ) | ||
|  | # https://www.rfc-editor.org/rfc/rfc2231#section-3 | ||
|  | _continuation_re = re.compile(r"\*(\d+)$", re.ASCII) | ||
|  | 
 | ||
|  | 
 | ||
|  | def parse_options_header(value: str | None) -> tuple[str, dict[str, str]]: | ||
|  |     """Parse a header that consists of a value with ``key=value`` parameters separated
 | ||
|  |     by semicolons ``;``. For example, the ``Content-Type`` header. | ||
|  | 
 | ||
|  |     .. code-block:: python | ||
|  | 
 | ||
|  |         parse_options_header("text/html; charset=UTF-8") | ||
|  |         ('text/html', {'charset': 'UTF-8'}) | ||
|  | 
 | ||
|  |         parse_options_header("") | ||
|  |         ("", {}) | ||
|  | 
 | ||
|  |     This is the reverse of :func:`dump_options_header`. | ||
|  | 
 | ||
|  |     This parses valid parameter parts as described in | ||
|  |     `RFC 9110 <https://httpwg.org/specs/rfc9110.html#parameter>`__. Invalid parts are | ||
|  |     skipped. | ||
|  | 
 | ||
|  |     This handles continuations and charsets as described in | ||
|  |     `RFC 2231 <https://www.rfc-editor.org/rfc/rfc2231#section-3>`__, although not as | ||
|  |     strictly as the RFC. Only ASCII, UTF-8, and ISO-8859-1 charsets are accepted, | ||
|  |     otherwise the value remains quoted. | ||
|  | 
 | ||
|  |     Clients may not be consistent in how they handle a quote character within a quoted | ||
|  |     value. The `HTML Standard <https://html.spec.whatwg.org/#multipart-form-data>`__ | ||
|  |     replaces it with ``%22`` in multipart form data. | ||
|  |     `RFC 9110 <https://httpwg.org/specs/rfc9110.html#quoted.strings>`__ uses backslash | ||
|  |     escapes in HTTP headers. Both are decoded to the ``"`` character. | ||
|  | 
 | ||
|  |     Clients may not be consistent in how they handle non-ASCII characters. HTML | ||
|  |     documents must declare ``<meta charset=UTF-8>``, otherwise browsers may replace with | ||
|  |     HTML character references, which can be decoded using :func:`html.unescape`. | ||
|  | 
 | ||
|  |     :param value: The header value to parse. | ||
|  |     :return: ``(value, options)``, where ``options`` is a dict | ||
|  | 
 | ||
|  |     .. versionchanged:: 2.3 | ||
|  |         Invalid parts, such as keys with no value, quoted keys, and incorrectly quoted | ||
|  |         values, are discarded instead of treating as ``None``. | ||
|  | 
 | ||
|  |     .. versionchanged:: 2.3 | ||
|  |         Only ASCII, UTF-8, and ISO-8859-1 are accepted for charset values. | ||
|  | 
 | ||
|  |     .. versionchanged:: 2.3 | ||
|  |         Escaped quotes in quoted values, like ``%22`` and ``\\"``, are handled. | ||
|  | 
 | ||
|  |     .. versionchanged:: 2.2 | ||
|  |         Option names are always converted to lowercase. | ||
|  | 
 | ||
|  |     .. versionchanged:: 2.2 | ||
|  |         The ``multiple`` parameter was removed. | ||
|  | 
 | ||
|  |     .. versionchanged:: 0.15 | ||
|  |         :rfc:`2231` parameter continuations are handled. | ||
|  | 
 | ||
|  |     .. versionadded:: 0.5 | ||
|  |     """
 | ||
|  |     if value is None: | ||
|  |         return "", {} | ||
|  | 
 | ||
|  |     value, _, rest = value.partition(";") | ||
|  |     value = value.strip() | ||
|  |     rest = rest.strip() | ||
|  | 
 | ||
|  |     if not value or not rest: | ||
|  |         # empty (invalid) value, or value without options | ||
|  |         return value, {} | ||
|  | 
 | ||
|  |     # Collect all valid key=value parts without processing the value. | ||
|  |     parts: list[tuple[str, str]] = [] | ||
|  | 
 | ||
|  |     while True: | ||
|  |         if (m := _parameter_key_re.match(rest)) is not None: | ||
|  |             pk = m.group(1).lower() | ||
|  |             rest = rest[m.end() :] | ||
|  | 
 | ||
|  |             # Value may be a token. | ||
|  |             if (m := _parameter_token_value_re.match(rest)) is not None: | ||
|  |                 parts.append((pk, m.group())) | ||
|  | 
 | ||
|  |             # Value may be a quoted string, find the closing quote. | ||
|  |             elif rest[:1] == '"': | ||
|  |                 pos = 1 | ||
|  |                 length = len(rest) | ||
|  | 
 | ||
|  |                 while pos < length: | ||
|  |                     if rest[pos : pos + 2] in {"\\\\", '\\"'}: | ||
|  |                         # Consume escaped slashes and quotes. | ||
|  |                         pos += 2 | ||
|  |                     elif rest[pos] == '"': | ||
|  |                         # Stop at an unescaped quote. | ||
|  |                         parts.append((pk, rest[: pos + 1])) | ||
|  |                         rest = rest[pos + 1 :] | ||
|  |                         break | ||
|  |                     else: | ||
|  |                         # Consume any other character. | ||
|  |                         pos += 1 | ||
|  | 
 | ||
|  |         # Find the next section delimited by `;`, if any. | ||
|  |         if (end := rest.find(";")) == -1: | ||
|  |             break | ||
|  | 
 | ||
|  |         rest = rest[end + 1 :].lstrip() | ||
|  | 
 | ||
|  |     options: dict[str, str] = {} | ||
|  |     encoding: str | None = None | ||
|  |     continued_encoding: str | None = None | ||
|  | 
 | ||
|  |     # For each collected part, process optional charset and continuation, | ||
|  |     # unquote quoted values. | ||
|  |     for pk, pv in parts: | ||
|  |         if pk[-1] == "*": | ||
|  |             # key*=charset''value becomes key=value, where value is percent encoded | ||
|  |             pk = pk[:-1] | ||
|  |             match = _charset_value_re.match(pv) | ||
|  | 
 | ||
|  |             if match: | ||
|  |                 # If there is a valid charset marker in the value, split it off. | ||
|  |                 encoding, pv = match.groups() | ||
|  |                 # This might be the empty string, handled next. | ||
|  |                 encoding = encoding.lower() | ||
|  | 
 | ||
|  |             # No charset marker, or marker with empty charset value. | ||
|  |             if not encoding: | ||
|  |                 encoding = continued_encoding | ||
|  | 
 | ||
|  |             # A safe list of encodings. Modern clients should only send ASCII or UTF-8. | ||
|  |             # This list will not be extended further. An invalid encoding will leave the | ||
|  |             # value quoted. | ||
|  |             if encoding in {"ascii", "us-ascii", "utf-8", "iso-8859-1"}: | ||
|  |                 # Continuation parts don't require their own charset marker. This is | ||
|  |                 # looser than the RFC, it will persist across different keys and allows | ||
|  |                 # changing the charset during a continuation. But this implementation is | ||
|  |                 # much simpler than tracking the full state. | ||
|  |                 continued_encoding = encoding | ||
|  |                 # invalid bytes are replaced during unquoting | ||
|  |                 pv = unquote(pv, encoding=encoding) | ||
|  | 
 | ||
|  |         # Remove quotes. At this point the value cannot be empty or a single quote. | ||
|  |         if pv[0] == pv[-1] == '"': | ||
|  |             # HTTP headers use slash, multipart form data uses percent | ||
|  |             pv = pv[1:-1].replace("\\\\", "\\").replace('\\"', '"').replace("%22", '"') | ||
|  | 
 | ||
|  |         match = _continuation_re.search(pk) | ||
|  | 
 | ||
|  |         if match: | ||
|  |             # key*0=a; key*1=b becomes key=ab | ||
|  |             pk = pk[: match.start()] | ||
|  |             options[pk] = options.get(pk, "") + pv | ||
|  |         else: | ||
|  |             options[pk] = pv | ||
|  | 
 | ||
|  |     return value, options | ||
|  | 
 | ||
|  | 
 | ||
|  | _q_value_re = re.compile(r"-?\d+(\.\d+)?", re.ASCII) | ||
|  | _TAnyAccept = t.TypeVar("_TAnyAccept", bound="ds.Accept") | ||
|  | 
 | ||
|  | 
 | ||
|  | @t.overload | ||
|  | def parse_accept_header(value: str | None) -> ds.Accept: ... | ||
|  | 
 | ||
|  | 
 | ||
|  | @t.overload | ||
|  | def parse_accept_header(value: str | None, cls: type[_TAnyAccept]) -> _TAnyAccept: ... | ||
|  | 
 | ||
|  | 
 | ||
|  | def parse_accept_header( | ||
|  |     value: str | None, cls: type[_TAnyAccept] | None = None | ||
|  | ) -> _TAnyAccept: | ||
|  |     """Parse an ``Accept`` header according to
 | ||
|  |     `RFC 9110 <https://httpwg.org/specs/rfc9110.html#field.accept>`__. | ||
|  | 
 | ||
|  |     Returns an :class:`.Accept` instance, which can sort and inspect items based on | ||
|  |     their quality parameter. When parsing ``Accept-Charset``, ``Accept-Encoding``, or | ||
|  |     ``Accept-Language``, pass the appropriate :class:`.Accept` subclass. | ||
|  | 
 | ||
|  |     :param value: The header value to parse. | ||
|  |     :param cls: The :class:`.Accept` class to wrap the result in. | ||
|  |     :return: An instance of ``cls``. | ||
|  | 
 | ||
|  |     .. versionchanged:: 2.3 | ||
|  |         Parse according to RFC 9110. Items with invalid ``q`` values are skipped. | ||
|  |     """
 | ||
|  |     if cls is None: | ||
|  |         cls = t.cast(type[_TAnyAccept], ds.Accept) | ||
|  | 
 | ||
|  |     if not value: | ||
|  |         return cls(None) | ||
|  | 
 | ||
|  |     result = [] | ||
|  | 
 | ||
|  |     for item in parse_list_header(value): | ||
|  |         item, options = parse_options_header(item) | ||
|  | 
 | ||
|  |         if "q" in options: | ||
|  |             # pop q, remaining options are reconstructed | ||
|  |             q_str = options.pop("q").strip() | ||
|  | 
 | ||
|  |             if _q_value_re.fullmatch(q_str) is None: | ||
|  |                 # ignore an invalid q | ||
|  |                 continue | ||
|  | 
 | ||
|  |             q = float(q_str) | ||
|  | 
 | ||
|  |             if q < 0 or q > 1: | ||
|  |                 # ignore an invalid q | ||
|  |                 continue | ||
|  |         else: | ||
|  |             q = 1 | ||
|  | 
 | ||
|  |         if options: | ||
|  |             # reconstruct the media type with any options | ||
|  |             item = dump_options_header(item, options) | ||
|  | 
 | ||
|  |         result.append((item, q)) | ||
|  | 
 | ||
|  |     return cls(result) | ||
|  | 
 | ||
|  | 
 | ||
|  | _TAnyCC = t.TypeVar("_TAnyCC", bound="ds.cache_control._CacheControl") | ||
|  | 
 | ||
|  | 
 | ||
|  | @t.overload | ||
|  | def parse_cache_control_header( | ||
|  |     value: str | None, | ||
|  |     on_update: t.Callable[[ds.cache_control._CacheControl], None] | None = None, | ||
|  | ) -> ds.RequestCacheControl: ... | ||
|  | 
 | ||
|  | 
 | ||
|  | @t.overload | ||
|  | def parse_cache_control_header( | ||
|  |     value: str | None, | ||
|  |     on_update: t.Callable[[ds.cache_control._CacheControl], None] | None = None, | ||
|  |     cls: type[_TAnyCC] = ..., | ||
|  | ) -> _TAnyCC: ... | ||
|  | 
 | ||
|  | 
 | ||
|  | def parse_cache_control_header( | ||
|  |     value: str | None, | ||
|  |     on_update: t.Callable[[ds.cache_control._CacheControl], None] | None = None, | ||
|  |     cls: type[_TAnyCC] | None = None, | ||
|  | ) -> _TAnyCC: | ||
|  |     """Parse a cache control header.  The RFC differs between response and
 | ||
|  |     request cache control, this method does not.  It's your responsibility | ||
|  |     to not use the wrong control statements. | ||
|  | 
 | ||
|  |     .. versionadded:: 0.5 | ||
|  |        The `cls` was added.  If not specified an immutable | ||
|  |        :class:`~werkzeug.datastructures.RequestCacheControl` is returned. | ||
|  | 
 | ||
|  |     :param value: a cache control header to be parsed. | ||
|  |     :param on_update: an optional callable that is called every time a value | ||
|  |                       on the :class:`~werkzeug.datastructures.CacheControl` | ||
|  |                       object is changed. | ||
|  |     :param cls: the class for the returned object.  By default | ||
|  |                 :class:`~werkzeug.datastructures.RequestCacheControl` is used. | ||
|  |     :return: a `cls` object. | ||
|  |     """
 | ||
|  |     if cls is None: | ||
|  |         cls = t.cast("type[_TAnyCC]", ds.RequestCacheControl) | ||
|  | 
 | ||
|  |     if not value: | ||
|  |         return cls((), on_update) | ||
|  | 
 | ||
|  |     return cls(parse_dict_header(value), on_update) | ||
|  | 
 | ||
|  | 
 | ||
|  | _TAnyCSP = t.TypeVar("_TAnyCSP", bound="ds.ContentSecurityPolicy") | ||
|  | 
 | ||
|  | 
 | ||
|  | @t.overload | ||
|  | def parse_csp_header( | ||
|  |     value: str | None, | ||
|  |     on_update: t.Callable[[ds.ContentSecurityPolicy], None] | None = None, | ||
|  | ) -> ds.ContentSecurityPolicy: ... | ||
|  | 
 | ||
|  | 
 | ||
|  | @t.overload | ||
|  | def parse_csp_header( | ||
|  |     value: str | None, | ||
|  |     on_update: t.Callable[[ds.ContentSecurityPolicy], None] | None = None, | ||
|  |     cls: type[_TAnyCSP] = ..., | ||
|  | ) -> _TAnyCSP: ... | ||
|  | 
 | ||
|  | 
 | ||
|  | def parse_csp_header( | ||
|  |     value: str | None, | ||
|  |     on_update: t.Callable[[ds.ContentSecurityPolicy], None] | None = None, | ||
|  |     cls: type[_TAnyCSP] | None = None, | ||
|  | ) -> _TAnyCSP: | ||
|  |     """Parse a Content Security Policy header.
 | ||
|  | 
 | ||
|  |     .. versionadded:: 1.0.0 | ||
|  |        Support for Content Security Policy headers was added. | ||
|  | 
 | ||
|  |     :param value: a csp header to be parsed. | ||
|  |     :param on_update: an optional callable that is called every time a value | ||
|  |                       on the object is changed. | ||
|  |     :param cls: the class for the returned object.  By default | ||
|  |                 :class:`~werkzeug.datastructures.ContentSecurityPolicy` is used. | ||
|  |     :return: a `cls` object. | ||
|  |     """
 | ||
|  |     if cls is None: | ||
|  |         cls = t.cast("type[_TAnyCSP]", ds.ContentSecurityPolicy) | ||
|  | 
 | ||
|  |     if value is None: | ||
|  |         return cls((), on_update) | ||
|  | 
 | ||
|  |     items = [] | ||
|  | 
 | ||
|  |     for policy in value.split(";"): | ||
|  |         policy = policy.strip() | ||
|  | 
 | ||
|  |         # Ignore badly formatted policies (no space) | ||
|  |         if " " in policy: | ||
|  |             directive, value = policy.strip().split(" ", 1) | ||
|  |             items.append((directive.strip(), value.strip())) | ||
|  | 
 | ||
|  |     return cls(items, on_update) | ||
|  | 
 | ||
|  | 
 | ||
|  | def parse_set_header( | ||
|  |     value: str | None, | ||
|  |     on_update: t.Callable[[ds.HeaderSet], None] | None = None, | ||
|  | ) -> ds.HeaderSet: | ||
|  |     """Parse a set-like header and return a
 | ||
|  |     :class:`~werkzeug.datastructures.HeaderSet` object: | ||
|  | 
 | ||
|  |     >>> hs = parse_set_header('token, "quoted value"') | ||
|  | 
 | ||
|  |     The return value is an object that treats the items case-insensitively | ||
|  |     and keeps the order of the items: | ||
|  | 
 | ||
|  |     >>> 'TOKEN' in hs | ||
|  |     True | ||
|  |     >>> hs.index('quoted value') | ||
|  |     1 | ||
|  |     >>> hs | ||
|  |     HeaderSet(['token', 'quoted value']) | ||
|  | 
 | ||
|  |     To create a header from the :class:`HeaderSet` again, use the | ||
|  |     :func:`dump_header` function. | ||
|  | 
 | ||
|  |     :param value: a set header to be parsed. | ||
|  |     :param on_update: an optional callable that is called every time a | ||
|  |                       value on the :class:`~werkzeug.datastructures.HeaderSet` | ||
|  |                       object is changed. | ||
|  |     :return: a :class:`~werkzeug.datastructures.HeaderSet` | ||
|  |     """
 | ||
|  |     if not value: | ||
|  |         return ds.HeaderSet(None, on_update) | ||
|  |     return ds.HeaderSet(parse_list_header(value), on_update) | ||
|  | 
 | ||
|  | 
 | ||
|  | def parse_if_range_header(value: str | None) -> ds.IfRange: | ||
|  |     """Parses an if-range header which can be an etag or a date.  Returns
 | ||
|  |     a :class:`~werkzeug.datastructures.IfRange` object. | ||
|  | 
 | ||
|  |     .. versionchanged:: 2.0 | ||
|  |         If the value represents a datetime, it is timezone-aware. | ||
|  | 
 | ||
|  |     .. versionadded:: 0.7 | ||
|  |     """
 | ||
|  |     if not value: | ||
|  |         return ds.IfRange() | ||
|  |     date = parse_date(value) | ||
|  |     if date is not None: | ||
|  |         return ds.IfRange(date=date) | ||
|  |     # drop weakness information | ||
|  |     return ds.IfRange(unquote_etag(value)[0]) | ||
|  | 
 | ||
|  | 
 | ||
|  | def parse_range_header( | ||
|  |     value: str | None, make_inclusive: bool = True | ||
|  | ) -> ds.Range | None: | ||
|  |     """Parses a range header into a :class:`~werkzeug.datastructures.Range`
 | ||
|  |     object.  If the header is missing or malformed `None` is returned. | ||
|  |     `ranges` is a list of ``(start, stop)`` tuples where the ranges are | ||
|  |     non-inclusive. | ||
|  | 
 | ||
|  |     .. versionadded:: 0.7 | ||
|  |     """
 | ||
|  |     if not value or "=" not in value: | ||
|  |         return None | ||
|  | 
 | ||
|  |     ranges = [] | ||
|  |     last_end = 0 | ||
|  |     units, rng = value.split("=", 1) | ||
|  |     units = units.strip().lower() | ||
|  | 
 | ||
|  |     for item in rng.split(","): | ||
|  |         item = item.strip() | ||
|  |         if "-" not in item: | ||
|  |             return None | ||
|  |         if item.startswith("-"): | ||
|  |             if last_end < 0: | ||
|  |                 return None | ||
|  |             try: | ||
|  |                 begin = _plain_int(item) | ||
|  |             except ValueError: | ||
|  |                 return None | ||
|  |             end = None | ||
|  |             last_end = -1 | ||
|  |         elif "-" in item: | ||
|  |             begin_str, end_str = item.split("-", 1) | ||
|  |             begin_str = begin_str.strip() | ||
|  |             end_str = end_str.strip() | ||
|  | 
 | ||
|  |             try: | ||
|  |                 begin = _plain_int(begin_str) | ||
|  |             except ValueError: | ||
|  |                 return None | ||
|  | 
 | ||
|  |             if begin < last_end or last_end < 0: | ||
|  |                 return None | ||
|  |             if end_str: | ||
|  |                 try: | ||
|  |                     end = _plain_int(end_str) + 1 | ||
|  |                 except ValueError: | ||
|  |                     return None | ||
|  | 
 | ||
|  |                 if begin >= end: | ||
|  |                     return None | ||
|  |             else: | ||
|  |                 end = None | ||
|  |             last_end = end if end is not None else -1 | ||
|  |         ranges.append((begin, end)) | ||
|  | 
 | ||
|  |     return ds.Range(units, ranges) | ||
|  | 
 | ||
|  | 
 | ||
|  | def parse_content_range_header( | ||
|  |     value: str | None, | ||
|  |     on_update: t.Callable[[ds.ContentRange], None] | None = None, | ||
|  | ) -> ds.ContentRange | None: | ||
|  |     """Parses a range header into a
 | ||
|  |     :class:`~werkzeug.datastructures.ContentRange` object or `None` if | ||
|  |     parsing is not possible. | ||
|  | 
 | ||
|  |     .. versionadded:: 0.7 | ||
|  | 
 | ||
|  |     :param value: a content range header to be parsed. | ||
|  |     :param on_update: an optional callable that is called every time a value | ||
|  |                       on the :class:`~werkzeug.datastructures.ContentRange` | ||
|  |                       object is changed. | ||
|  |     """
 | ||
|  |     if value is None: | ||
|  |         return None | ||
|  |     try: | ||
|  |         units, rangedef = (value or "").strip().split(None, 1) | ||
|  |     except ValueError: | ||
|  |         return None | ||
|  | 
 | ||
|  |     if "/" not in rangedef: | ||
|  |         return None | ||
|  |     rng, length_str = rangedef.split("/", 1) | ||
|  |     if length_str == "*": | ||
|  |         length = None | ||
|  |     else: | ||
|  |         try: | ||
|  |             length = _plain_int(length_str) | ||
|  |         except ValueError: | ||
|  |             return None | ||
|  | 
 | ||
|  |     if rng == "*": | ||
|  |         if not is_byte_range_valid(None, None, length): | ||
|  |             return None | ||
|  | 
 | ||
|  |         return ds.ContentRange(units, None, None, length, on_update=on_update) | ||
|  |     elif "-" not in rng: | ||
|  |         return None | ||
|  | 
 | ||
|  |     start_str, stop_str = rng.split("-", 1) | ||
|  |     try: | ||
|  |         start = _plain_int(start_str) | ||
|  |         stop = _plain_int(stop_str) + 1 | ||
|  |     except ValueError: | ||
|  |         return None | ||
|  | 
 | ||
|  |     if is_byte_range_valid(start, stop, length): | ||
|  |         return ds.ContentRange(units, start, stop, length, on_update=on_update) | ||
|  | 
 | ||
|  |     return None | ||
|  | 
 | ||
|  | 
 | ||
|  | def quote_etag(etag: str, weak: bool = False) -> str: | ||
|  |     """Quote an etag.
 | ||
|  | 
 | ||
|  |     :param etag: the etag to quote. | ||
|  |     :param weak: set to `True` to tag it "weak". | ||
|  |     """
 | ||
|  |     if '"' in etag: | ||
|  |         raise ValueError("invalid etag") | ||
|  |     etag = f'"{etag}"' | ||
|  |     if weak: | ||
|  |         etag = f"W/{etag}" | ||
|  |     return etag | ||
|  | 
 | ||
|  | 
 | ||
|  | @t.overload | ||
|  | def unquote_etag(etag: str) -> tuple[str, bool]: ... | ||
|  | @t.overload | ||
|  | def unquote_etag(etag: None) -> tuple[None, None]: ... | ||
|  | def unquote_etag( | ||
|  |     etag: str | None, | ||
|  | ) -> tuple[str, bool] | tuple[None, None]: | ||
|  |     """Unquote a single etag:
 | ||
|  | 
 | ||
|  |     >>> unquote_etag('W/"bar"') | ||
|  |     ('bar', True) | ||
|  |     >>> unquote_etag('"bar"') | ||
|  |     ('bar', False) | ||
|  | 
 | ||
|  |     :param etag: the etag identifier to unquote. | ||
|  |     :return: a ``(etag, weak)`` tuple. | ||
|  |     """
 | ||
|  |     if not etag: | ||
|  |         return None, None | ||
|  |     etag = etag.strip() | ||
|  |     weak = False | ||
|  |     if etag.startswith(("W/", "w/")): | ||
|  |         weak = True | ||
|  |         etag = etag[2:] | ||
|  |     if etag[:1] == etag[-1:] == '"': | ||
|  |         etag = etag[1:-1] | ||
|  |     return etag, weak | ||
|  | 
 | ||
|  | 
 | ||
|  | def parse_etags(value: str | None) -> ds.ETags: | ||
|  |     """Parse an etag header.
 | ||
|  | 
 | ||
|  |     :param value: the tag header to parse | ||
|  |     :return: an :class:`~werkzeug.datastructures.ETags` object. | ||
|  |     """
 | ||
|  |     if not value: | ||
|  |         return ds.ETags() | ||
|  |     strong = [] | ||
|  |     weak = [] | ||
|  |     end = len(value) | ||
|  |     pos = 0 | ||
|  |     while pos < end: | ||
|  |         match = _etag_re.match(value, pos) | ||
|  |         if match is None: | ||
|  |             break | ||
|  |         is_weak, quoted, raw = match.groups() | ||
|  |         if raw == "*": | ||
|  |             return ds.ETags(star_tag=True) | ||
|  |         elif quoted: | ||
|  |             raw = quoted | ||
|  |         if is_weak: | ||
|  |             weak.append(raw) | ||
|  |         else: | ||
|  |             strong.append(raw) | ||
|  |         pos = match.end() | ||
|  |     return ds.ETags(strong, weak) | ||
|  | 
 | ||
|  | 
 | ||
|  | def generate_etag(data: bytes) -> str: | ||
|  |     """Generate an etag for some data.
 | ||
|  | 
 | ||
|  |     .. versionchanged:: 2.0 | ||
|  |         Use SHA-1. MD5 may not be available in some environments. | ||
|  |     """
 | ||
|  |     return sha1(data).hexdigest() | ||
|  | 
 | ||
|  | 
 | ||
|  | def parse_date(value: str | None) -> datetime | None: | ||
|  |     """Parse an :rfc:`2822` date into a timezone-aware
 | ||
|  |     :class:`datetime.datetime` object, or ``None`` if parsing fails. | ||
|  | 
 | ||
|  |     This is a wrapper for :func:`email.utils.parsedate_to_datetime`. It | ||
|  |     returns ``None`` if parsing fails instead of raising an exception, | ||
|  |     and always returns a timezone-aware datetime object. If the string | ||
|  |     doesn't have timezone information, it is assumed to be UTC. | ||
|  | 
 | ||
|  |     :param value: A string with a supported date format. | ||
|  | 
 | ||
|  |     .. versionchanged:: 2.0 | ||
|  |         Return a timezone-aware datetime object. Use | ||
|  |         ``email.utils.parsedate_to_datetime``. | ||
|  |     """
 | ||
|  |     if value is None: | ||
|  |         return None | ||
|  | 
 | ||
|  |     try: | ||
|  |         dt = email.utils.parsedate_to_datetime(value) | ||
|  |     except (TypeError, ValueError): | ||
|  |         return None | ||
|  | 
 | ||
|  |     if dt.tzinfo is None: | ||
|  |         return dt.replace(tzinfo=timezone.utc) | ||
|  | 
 | ||
|  |     return dt | ||
|  | 
 | ||
|  | 
 | ||
|  | def http_date( | ||
|  |     timestamp: datetime | date | int | float | struct_time | None = None, | ||
|  | ) -> str: | ||
|  |     """Format a datetime object or timestamp into an :rfc:`2822` date
 | ||
|  |     string. | ||
|  | 
 | ||
|  |     This is a wrapper for :func:`email.utils.format_datetime`. It | ||
|  |     assumes naive datetime objects are in UTC instead of raising an | ||
|  |     exception. | ||
|  | 
 | ||
|  |     :param timestamp: The datetime or timestamp to format. Defaults to | ||
|  |         the current time. | ||
|  | 
 | ||
|  |     .. versionchanged:: 2.0 | ||
|  |         Use ``email.utils.format_datetime``. Accept ``date`` objects. | ||
|  |     """
 | ||
|  |     if isinstance(timestamp, date): | ||
|  |         if not isinstance(timestamp, datetime): | ||
|  |             # Assume plain date is midnight UTC. | ||
|  |             timestamp = datetime.combine(timestamp, time(), tzinfo=timezone.utc) | ||
|  |         else: | ||
|  |             # Ensure datetime is timezone-aware. | ||
|  |             timestamp = _dt_as_utc(timestamp) | ||
|  | 
 | ||
|  |         return email.utils.format_datetime(timestamp, usegmt=True) | ||
|  | 
 | ||
|  |     if isinstance(timestamp, struct_time): | ||
|  |         timestamp = mktime(timestamp) | ||
|  | 
 | ||
|  |     return email.utils.formatdate(timestamp, usegmt=True) | ||
|  | 
 | ||
|  | 
 | ||
|  | def parse_age(value: str | None = None) -> timedelta | None: | ||
|  |     """Parses a base-10 integer count of seconds into a timedelta.
 | ||
|  | 
 | ||
|  |     If parsing fails, the return value is `None`. | ||
|  | 
 | ||
|  |     :param value: a string consisting of an integer represented in base-10 | ||
|  |     :return: a :class:`datetime.timedelta` object or `None`. | ||
|  |     """
 | ||
|  |     if not value: | ||
|  |         return None | ||
|  |     try: | ||
|  |         seconds = int(value) | ||
|  |     except ValueError: | ||
|  |         return None | ||
|  |     if seconds < 0: | ||
|  |         return None | ||
|  |     try: | ||
|  |         return timedelta(seconds=seconds) | ||
|  |     except OverflowError: | ||
|  |         return None | ||
|  | 
 | ||
|  | 
 | ||
|  | def dump_age(age: timedelta | int | None = None) -> str | None: | ||
|  |     """Formats the duration as a base-10 integer.
 | ||
|  | 
 | ||
|  |     :param age: should be an integer number of seconds, | ||
|  |                 a :class:`datetime.timedelta` object, or, | ||
|  |                 if the age is unknown, `None` (default). | ||
|  |     """
 | ||
|  |     if age is None: | ||
|  |         return None | ||
|  |     if isinstance(age, timedelta): | ||
|  |         age = int(age.total_seconds()) | ||
|  |     else: | ||
|  |         age = int(age) | ||
|  | 
 | ||
|  |     if age < 0: | ||
|  |         raise ValueError("age cannot be negative") | ||
|  | 
 | ||
|  |     return str(age) | ||
|  | 
 | ||
|  | 
 | ||
|  | def is_resource_modified( | ||
|  |     environ: WSGIEnvironment, | ||
|  |     etag: str | None = None, | ||
|  |     data: bytes | None = None, | ||
|  |     last_modified: datetime | str | None = None, | ||
|  |     ignore_if_range: bool = True, | ||
|  | ) -> bool: | ||
|  |     """Convenience method for conditional requests.
 | ||
|  | 
 | ||
|  |     :param environ: the WSGI environment of the request to be checked. | ||
|  |     :param etag: the etag for the response for comparison. | ||
|  |     :param data: or alternatively the data of the response to automatically | ||
|  |                  generate an etag using :func:`generate_etag`. | ||
|  |     :param last_modified: an optional date of the last modification. | ||
|  |     :param ignore_if_range: If `False`, `If-Range` header will be taken into | ||
|  |                             account. | ||
|  |     :return: `True` if the resource was modified, otherwise `False`. | ||
|  | 
 | ||
|  |     .. versionchanged:: 2.0 | ||
|  |         SHA-1 is used to generate an etag value for the data. MD5 may | ||
|  |         not be available in some environments. | ||
|  | 
 | ||
|  |     .. versionchanged:: 1.0.0 | ||
|  |         The check is run for methods other than ``GET`` and ``HEAD``. | ||
|  |     """
 | ||
|  |     return _sansio_http.is_resource_modified( | ||
|  |         http_range=environ.get("HTTP_RANGE"), | ||
|  |         http_if_range=environ.get("HTTP_IF_RANGE"), | ||
|  |         http_if_modified_since=environ.get("HTTP_IF_MODIFIED_SINCE"), | ||
|  |         http_if_none_match=environ.get("HTTP_IF_NONE_MATCH"), | ||
|  |         http_if_match=environ.get("HTTP_IF_MATCH"), | ||
|  |         etag=etag, | ||
|  |         data=data, | ||
|  |         last_modified=last_modified, | ||
|  |         ignore_if_range=ignore_if_range, | ||
|  |     ) | ||
|  | 
 | ||
|  | 
 | ||
|  | def remove_entity_headers( | ||
|  |     headers: ds.Headers | list[tuple[str, str]], | ||
|  |     allowed: t.Iterable[str] = ("expires", "content-location"), | ||
|  | ) -> None: | ||
|  |     """Remove all entity headers from a list or :class:`Headers` object.  This
 | ||
|  |     operation works in-place.  `Expires` and `Content-Location` headers are | ||
|  |     by default not removed.  The reason for this is :rfc:`2616` section | ||
|  |     10.3.5 which specifies some entity headers that should be sent. | ||
|  | 
 | ||
|  |     .. versionchanged:: 0.5 | ||
|  |        added `allowed` parameter. | ||
|  | 
 | ||
|  |     :param headers: a list or :class:`Headers` object. | ||
|  |     :param allowed: a list of headers that should still be allowed even though | ||
|  |                     they are entity headers. | ||
|  |     """
 | ||
|  |     allowed = {x.lower() for x in allowed} | ||
|  |     headers[:] = [ | ||
|  |         (key, value) | ||
|  |         for key, value in headers | ||
|  |         if not is_entity_header(key) or key.lower() in allowed | ||
|  |     ] | ||
|  | 
 | ||
|  | 
 | ||
|  | def remove_hop_by_hop_headers(headers: ds.Headers | list[tuple[str, str]]) -> None: | ||
|  |     """Remove all HTTP/1.1 "Hop-by-Hop" headers from a list or
 | ||
|  |     :class:`Headers` object.  This operation works in-place. | ||
|  | 
 | ||
|  |     .. versionadded:: 0.5 | ||
|  | 
 | ||
|  |     :param headers: a list or :class:`Headers` object. | ||
|  |     """
 | ||
|  |     headers[:] = [ | ||
|  |         (key, value) for key, value in headers if not is_hop_by_hop_header(key) | ||
|  |     ] | ||
|  | 
 | ||
|  | 
 | ||
|  | def is_entity_header(header: str) -> bool: | ||
|  |     """Check if a header is an entity header.
 | ||
|  | 
 | ||
|  |     .. versionadded:: 0.5 | ||
|  | 
 | ||
|  |     :param header: the header to test. | ||
|  |     :return: `True` if it's an entity header, `False` otherwise. | ||
|  |     """
 | ||
|  |     return header.lower() in _entity_headers | ||
|  | 
 | ||
|  | 
 | ||
|  | def is_hop_by_hop_header(header: str) -> bool: | ||
|  |     """Check if a header is an HTTP/1.1 "Hop-by-Hop" header.
 | ||
|  | 
 | ||
|  |     .. versionadded:: 0.5 | ||
|  | 
 | ||
|  |     :param header: the header to test. | ||
|  |     :return: `True` if it's an HTTP/1.1 "Hop-by-Hop" header, `False` otherwise. | ||
|  |     """
 | ||
|  |     return header.lower() in _hop_by_hop_headers | ||
|  | 
 | ||
|  | 
 | ||
|  | def parse_cookie( | ||
|  |     header: WSGIEnvironment | str | None, | ||
|  |     cls: type[ds.MultiDict[str, str]] | None = None, | ||
|  | ) -> ds.MultiDict[str, str]: | ||
|  |     """Parse a cookie from a string or WSGI environ.
 | ||
|  | 
 | ||
|  |     The same key can be provided multiple times, the values are stored | ||
|  |     in-order. The default :class:`MultiDict` will have the first value | ||
|  |     first, and all values can be retrieved with | ||
|  |     :meth:`MultiDict.getlist`. | ||
|  | 
 | ||
|  |     :param header: The cookie header as a string, or a WSGI environ dict | ||
|  |         with a ``HTTP_COOKIE`` key. | ||
|  |     :param cls: A dict-like class to store the parsed cookies in. | ||
|  |         Defaults to :class:`MultiDict`. | ||
|  | 
 | ||
|  |     .. versionchanged:: 3.0 | ||
|  |         Passing bytes, and the ``charset`` and ``errors`` parameters, were removed. | ||
|  | 
 | ||
|  |     .. versionchanged:: 1.0 | ||
|  |         Returns a :class:`MultiDict` instead of a ``TypeConversionDict``. | ||
|  | 
 | ||
|  |     .. versionchanged:: 0.5 | ||
|  |         Returns a :class:`TypeConversionDict` instead of a regular dict. The ``cls`` | ||
|  |         parameter was added. | ||
|  |     """
 | ||
|  |     if isinstance(header, dict): | ||
|  |         cookie = header.get("HTTP_COOKIE") | ||
|  |     else: | ||
|  |         cookie = header | ||
|  | 
 | ||
|  |     if cookie: | ||
|  |         cookie = cookie.encode("latin1").decode() | ||
|  | 
 | ||
|  |     return _sansio_http.parse_cookie(cookie=cookie, cls=cls) | ||
|  | 
 | ||
|  | 
 | ||
|  | _cookie_no_quote_re = re.compile(r"[\w!#$%&'()*+\-./:<=>?@\[\]^`{|}~]*", re.A) | ||
|  | _cookie_slash_re = re.compile(rb"[\x00-\x19\",;\\\x7f-\xff]", re.A) | ||
|  | _cookie_slash_map = {b'"': b'\\"', b"\\": b"\\\\"} | ||
|  | _cookie_slash_map.update( | ||
|  |     (v.to_bytes(1, "big"), b"\\%03o" % v) | ||
|  |     for v in [*range(0x20), *b",;", *range(0x7F, 256)] | ||
|  | ) | ||
|  | 
 | ||
|  | 
 | ||
|  | def dump_cookie( | ||
|  |     key: str, | ||
|  |     value: str = "", | ||
|  |     max_age: timedelta | int | None = None, | ||
|  |     expires: str | datetime | int | float | None = None, | ||
|  |     path: str | None = "/", | ||
|  |     domain: str | None = None, | ||
|  |     secure: bool = False, | ||
|  |     httponly: bool = False, | ||
|  |     sync_expires: bool = True, | ||
|  |     max_size: int = 4093, | ||
|  |     samesite: str | None = None, | ||
|  |     partitioned: bool = False, | ||
|  | ) -> str: | ||
|  |     """Create a Set-Cookie header without the ``Set-Cookie`` prefix.
 | ||
|  | 
 | ||
|  |     The return value is usually restricted to ascii as the vast majority | ||
|  |     of values are properly escaped, but that is no guarantee. It's | ||
|  |     tunneled through latin1 as required by :pep:`3333`. | ||
|  | 
 | ||
|  |     The return value is not ASCII safe if the key contains unicode | ||
|  |     characters.  This is technically against the specification but | ||
|  |     happens in the wild.  It's strongly recommended to not use | ||
|  |     non-ASCII values for the keys. | ||
|  | 
 | ||
|  |     :param max_age: should be a number of seconds, or `None` (default) if | ||
|  |                     the cookie should last only as long as the client's | ||
|  |                     browser session.  Additionally `timedelta` objects | ||
|  |                     are accepted, too. | ||
|  |     :param expires: should be a `datetime` object or unix timestamp. | ||
|  |     :param path: limits the cookie to a given path, per default it will | ||
|  |                  span the whole domain. | ||
|  |     :param domain: Use this if you want to set a cross-domain cookie. For | ||
|  |                    example, ``domain="example.com"`` will set a cookie | ||
|  |                    that is readable by the domain ``www.example.com``, | ||
|  |                    ``foo.example.com`` etc. Otherwise, a cookie will only | ||
|  |                    be readable by the domain that set it. | ||
|  |     :param secure: The cookie will only be available via HTTPS | ||
|  |     :param httponly: disallow JavaScript to access the cookie.  This is an | ||
|  |                      extension to the cookie standard and probably not | ||
|  |                      supported by all browsers. | ||
|  |     :param charset: the encoding for string values. | ||
|  |     :param sync_expires: automatically set expires if max_age is defined | ||
|  |                          but expires not. | ||
|  |     :param max_size: Warn if the final header value exceeds this size. The | ||
|  |         default, 4093, should be safely `supported by most browsers | ||
|  |         <cookie_>`_. Set to 0 to disable this check. | ||
|  |     :param samesite: Limits the scope of the cookie such that it will | ||
|  |         only be attached to requests if those requests are same-site. | ||
|  |     :param partitioned: Opts the cookie into partitioned storage. This | ||
|  |         will also set secure to True | ||
|  | 
 | ||
|  |     .. _`cookie`: http://browsercookielimits.squawky.net/ | ||
|  | 
 | ||
|  |     .. versionchanged:: 3.1 | ||
|  |         The ``partitioned`` parameter was added. | ||
|  | 
 | ||
|  |     .. versionchanged:: 3.0 | ||
|  |         Passing bytes, and the ``charset`` parameter, were removed. | ||
|  | 
 | ||
|  |     .. versionchanged:: 2.3.3 | ||
|  |         The ``path`` parameter is ``/`` by default. | ||
|  | 
 | ||
|  |     .. versionchanged:: 2.3.1 | ||
|  |         The value allows more characters without quoting. | ||
|  | 
 | ||
|  |     .. versionchanged:: 2.3 | ||
|  |         ``localhost`` and other names without a dot are allowed for the domain. A | ||
|  |         leading dot is ignored. | ||
|  | 
 | ||
|  |     .. versionchanged:: 2.3 | ||
|  |         The ``path`` parameter is ``None`` by default. | ||
|  | 
 | ||
|  |     .. versionchanged:: 1.0.0 | ||
|  |         The string ``'None'`` is accepted for ``samesite``. | ||
|  |     """
 | ||
|  |     if path is not None: | ||
|  |         # safe = https://url.spec.whatwg.org/#url-path-segment-string | ||
|  |         # as well as percent for things that are already quoted | ||
|  |         # excluding semicolon since it's part of the header syntax | ||
|  |         path = quote(path, safe="%!$&'()*+,/:=@") | ||
|  | 
 | ||
|  |     if domain: | ||
|  |         domain = domain.partition(":")[0].lstrip(".").encode("idna").decode("ascii") | ||
|  | 
 | ||
|  |     if isinstance(max_age, timedelta): | ||
|  |         max_age = int(max_age.total_seconds()) | ||
|  | 
 | ||
|  |     if expires is not None: | ||
|  |         if not isinstance(expires, str): | ||
|  |             expires = http_date(expires) | ||
|  |     elif max_age is not None and sync_expires: | ||
|  |         expires = http_date(datetime.now(tz=timezone.utc).timestamp() + max_age) | ||
|  | 
 | ||
|  |     if samesite is not None: | ||
|  |         samesite = samesite.title() | ||
|  | 
 | ||
|  |         if samesite not in {"Strict", "Lax", "None"}: | ||
|  |             raise ValueError("SameSite must be 'Strict', 'Lax', or 'None'.") | ||
|  | 
 | ||
|  |     if partitioned: | ||
|  |         secure = True | ||
|  | 
 | ||
|  |     # Quote value if it contains characters not allowed by RFC 6265. Slash-escape with | ||
|  |     # three octal digits, which matches http.cookies, although the RFC suggests base64. | ||
|  |     if not _cookie_no_quote_re.fullmatch(value): | ||
|  |         # Work with bytes here, since a UTF-8 character could be multiple bytes. | ||
|  |         value = _cookie_slash_re.sub( | ||
|  |             lambda m: _cookie_slash_map[m.group()], value.encode() | ||
|  |         ).decode("ascii") | ||
|  |         value = f'"{value}"' | ||
|  | 
 | ||
|  |     # Send a non-ASCII key as mojibake. Everything else should already be ASCII. | ||
|  |     # TODO Remove encoding dance, it seems like clients accept UTF-8 keys | ||
|  |     buf = [f"{key.encode().decode('latin1')}={value}"] | ||
|  | 
 | ||
|  |     for k, v in ( | ||
|  |         ("Domain", domain), | ||
|  |         ("Expires", expires), | ||
|  |         ("Max-Age", max_age), | ||
|  |         ("Secure", secure), | ||
|  |         ("HttpOnly", httponly), | ||
|  |         ("Path", path), | ||
|  |         ("SameSite", samesite), | ||
|  |         ("Partitioned", partitioned), | ||
|  |     ): | ||
|  |         if v is None or v is False: | ||
|  |             continue | ||
|  | 
 | ||
|  |         if v is True: | ||
|  |             buf.append(k) | ||
|  |             continue | ||
|  | 
 | ||
|  |         buf.append(f"{k}={v}") | ||
|  | 
 | ||
|  |     rv = "; ".join(buf) | ||
|  | 
 | ||
|  |     # Warn if the final value of the cookie is larger than the limit. If the cookie is | ||
|  |     # too large, then it may be silently ignored by the browser, which can be quite hard | ||
|  |     # to debug. | ||
|  |     cookie_size = len(rv) | ||
|  | 
 | ||
|  |     if max_size and cookie_size > max_size: | ||
|  |         value_size = len(value) | ||
|  |         warnings.warn( | ||
|  |             f"The '{key}' cookie is too large: the value was {value_size} bytes but the" | ||
|  |             f" header required {cookie_size - value_size} extra bytes. The final size" | ||
|  |             f" was {cookie_size} bytes but the limit is {max_size} bytes. Browsers may" | ||
|  |             " silently ignore cookies larger than this.", | ||
|  |             stacklevel=2, | ||
|  |         ) | ||
|  | 
 | ||
|  |     return rv | ||
|  | 
 | ||
|  | 
 | ||
|  | def is_byte_range_valid( | ||
|  |     start: int | None, stop: int | None, length: int | None | ||
|  | ) -> bool: | ||
|  |     """Checks if a given byte content range is valid for the given length.
 | ||
|  | 
 | ||
|  |     .. versionadded:: 0.7 | ||
|  |     """
 | ||
|  |     if (start is None) != (stop is None): | ||
|  |         return False | ||
|  |     elif start is None: | ||
|  |         return length is None or length >= 0 | ||
|  |     elif length is None: | ||
|  |         return 0 <= start < stop  # type: ignore | ||
|  |     elif start >= stop:  # type: ignore | ||
|  |         return False | ||
|  |     return 0 <= start < length | ||
|  | 
 | ||
|  | 
 | ||
|  | # circular dependencies | ||
|  | from . import datastructures as ds | ||
|  | from .sansio import http as _sansio_http |