codekingpro/portable-devtools
114k
1from __future__ import annotations2 3import email.utils4import re5import typing as t6import warnings7from datetime import date8from datetime import datetime9from datetime import time10from datetime import timedelta11from datetime import timezone12from enum import Enum13from hashlib import sha114from time import mktime15from time import struct_time16from urllib.parse import quote17from urllib.parse import unquote18 19from ._internal import _dt_as_utc20from ._internal import _plain_int21 22if t.TYPE_CHECKING:23 from _typeshed.wsgi import WSGIEnvironment24 25_token_chars = frozenset(26 "!#$%&'*+-.0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ^_`abcdefghijklmnopqrstuvwxyz|~"27)28_etag_re = re.compile(r'([Ww]/)?(?:"(.*?)"|(.*?))(?:\s*,\s*|$)')29_entity_headers = frozenset(30 [31 "allow",32 "content-encoding",33 "content-language",34 "content-length",35 "content-location",36 "content-md5",37 "content-range",38 "content-type",39 "expires",40 "last-modified",41 ]42)43_hop_by_hop_headers = frozenset(44 [45 "connection",46 "keep-alive",47 "proxy-authenticate",48 "proxy-authorization",49 "te",50 "trailer",51 "transfer-encoding",52 "upgrade",53 ]54)55HTTP_STATUS_CODES = {56 100: "Continue",57 101: "Switching Protocols",58 102: "Processing",59 103: "Early Hints", # see RFC 829760 200: "OK",61 201: "Created",62 202: "Accepted",63 203: "Non Authoritative Information",64 204: "No Content",65 205: "Reset Content",66 206: "Partial Content",67 207: "Multi Status",68 208: "Already Reported", # see RFC 584269 226: "IM Used", # see RFC 322970 300: "Multiple Choices",71 301: "Moved Permanently",72 302: "Found",73 303: "See Other",74 304: "Not Modified",75 305: "Use Proxy",76 306: "Switch Proxy", # unused77 307: "Temporary Redirect",78 308: "Permanent Redirect",79 400: "Bad Request",80 401: "Unauthorized",81 402: "Payment Required", # unused82 403: "Forbidden",83 404: "Not Found",84 405: "Method Not Allowed",85 406: "Not Acceptable",86 407: "Proxy Authentication Required",87 408: "Request Timeout",88 409: "Conflict",89 410: "Gone",90 411: "Length Required",91 412: "Precondition Failed",92 413: "Request Entity Too Large",93 414: "Request URI Too Long",94 415: "Unsupported Media Type",95 416: "Requested Range Not Satisfiable",96 417: "Expectation Failed",97 418: "I'm a teapot", # see RFC 232498 421: "Misdirected Request", # see RFC 754099 422: "Unprocessable Entity",100 423: "Locked",101 424: "Failed Dependency",102 425: "Too Early", # see RFC 8470103 426: "Upgrade Required",104 428: "Precondition Required", # see RFC 6585105 429: "Too Many Requests",106 431: "Request Header Fields Too Large",107 449: "Retry With", # proprietary MS extension108 451: "Unavailable For Legal Reasons",109 500: "Internal Server Error",110 501: "Not Implemented",111 502: "Bad Gateway",112 503: "Service Unavailable",113 504: "Gateway Timeout",114 505: "HTTP Version Not Supported",115 506: "Variant Also Negotiates", # see RFC 2295116 507: "Insufficient Storage",117 508: "Loop Detected", # see RFC 5842118 510: "Not Extended",119 511: "Network Authentication Failed",120}121 122 123class COEP(Enum):124 """Cross Origin Embedder Policies"""125 126 UNSAFE_NONE = "unsafe-none"127 REQUIRE_CORP = "require-corp"128 129 130class COOP(Enum):131 """Cross Origin Opener Policies"""132 133 UNSAFE_NONE = "unsafe-none"134 SAME_ORIGIN_ALLOW_POPUPS = "same-origin-allow-popups"135 SAME_ORIGIN = "same-origin"136 137 138def quote_header_value(value: t.Any, allow_token: bool = True) -> str:139 """Add double quotes around a header value. If the header contains only ASCII token140 characters, it will be returned unchanged. If the header contains ``"`` or ``\\``141 characters, they will be escaped with an additional ``\\`` character.142 143 This is the reverse of :func:`unquote_header_value`.144 145 :param value: The value to quote. Will be converted to a string.146 :param allow_token: Disable to quote the value even if it only has token characters.147 148 .. versionchanged:: 3.0149 Passing bytes is not supported.150 151 .. versionchanged:: 3.0152 The ``extra_chars`` parameter is removed.153 154 .. versionchanged:: 2.3155 The value is quoted if it is the empty string.156 157 .. versionadded:: 0.5158 """159 value_str = str(value)160 161 if not value_str:162 return '""'163 164 if allow_token:165 token_chars = _token_chars166 167 if token_chars.issuperset(value_str):168 return value_str169 170 value_str = value_str.replace("\\", "\\\\").replace('"', '\\"')171 return f'"{value_str}"'172 173 174_unslash_re = re.compile(r"\\(.)", re.A)175 176 177def unquote_header_value(value: str) -> str:178 """Remove double quotes and backslash escapes from a header value.179 180 This is the reverse of :func:`quote_header_value`.181 182 :param value: The header value to unquote.183 184 .. versionchanged:: 3.2185 Removes escape preceding any character.186 187 .. versionchanged:: 3.0188 The ``is_filename`` parameter is removed.189 """190 if len(value) >= 2 and value[0] == value[-1] == '"':191 return _unslash_re.sub(r"\g<1>", value[1:-1])192 193 return value194 195 196def dump_options_header(header: str | None, options: t.Mapping[str, t.Any]) -> str:197 """Produce a header value and ``key=value`` parameters separated by semicolons198 ``;``. For example, the ``Content-Type`` header.199 200 .. code-block:: python201 202 dump_options_header("text/html", {"charset": "UTF-8"})203 'text/html; charset=UTF-8'204 205 This is the reverse of :func:`parse_options_header`.206 207 If a value contains non-token characters, it will be quoted.208 209 If a value is ``None``, the parameter is skipped.210 211 In some keys for some headers, a UTF-8 value can be encoded using a special212 ``key*=UTF-8''value`` form, where ``value`` is percent encoded. This function will213 not produce that format automatically, but if a given key ends with an asterisk214 ``*``, the value is assumed to have that form and will not be quoted further.215 216 :param header: The primary header value.217 :param options: Parameters to encode as ``key=value`` pairs.218 219 .. versionchanged:: 2.3220 Keys with ``None`` values are skipped rather than treated as a bare key.221 222 .. versionchanged:: 2.2.3223 If a key ends with ``*``, its value will not be quoted.224 """225 segments = []226 227 if header is not None:228 segments.append(header)229 230 for key, value in options.items():231 if value is None:232 continue233 234 if key[-1] == "*":235 segments.append(f"{key}={value}")236 else:237 segments.append(f"{key}={quote_header_value(value)}")238 239 return "; ".join(segments)240 241 242def dump_header(iterable: dict[str, t.Any] | t.Iterable[t.Any]) -> str:243 """Produce a header value from a list of items or ``key=value`` pairs, separated by244 commas ``,``.245 246 This is the reverse of :func:`parse_list_header`, :func:`parse_dict_header`, and247 :func:`parse_set_header`.248 249 If a value contains non-token characters, it will be quoted.250 251 If a value is ``None``, the key is output alone.252 253 In some keys for some headers, a UTF-8 value can be encoded using a special254 ``key*=UTF-8''value`` form, where ``value`` is percent encoded. This function will255 not produce that format automatically, but if a given key ends with an asterisk256 ``*``, the value is assumed to have that form and will not be quoted further.257 258 .. code-block:: python259 260 dump_header(["foo", "bar baz"])261 'foo, "bar baz"'262 263 dump_header({"foo": "bar baz"})264 'foo="bar baz"'265 266 :param iterable: The items to create a header from.267 268 .. versionchanged:: 3.0269 The ``allow_token`` parameter is removed.270 271 .. versionchanged:: 2.2.3272 If a key ends with ``*``, its value will not be quoted.273 """274 if isinstance(iterable, dict):275 items = []276 277 for key, value in iterable.items():278 if value is None:279 items.append(key)280 elif key[-1] == "*":281 items.append(f"{key}={value}")282 else:283 items.append(f"{key}={quote_header_value(value)}")284 else:285 items = [quote_header_value(x) for x in iterable]286 287 return ", ".join(items)288 289 290def dump_csp_header(header: ds.ContentSecurityPolicy) -> str:291 """Dump a Content Security Policy header.292 293 These are structured into policies such as "default-src 'self';294 script-src 'self'".295 296 .. versionadded:: 1.0.0297 Support for Content Security Policy headers was added.298 299 """300 return "; ".join(f"{key} {value}" for key, value in header.items())301 302 303def parse_list_header(value: str) -> list[str]:304 """Parse a header value that consists of a list of comma separated items according305 to `RFC 9110 <https://httpwg.org/specs/rfc9110.html#abnf.extension>`__.306 307 Surrounding quotes are removed from items, but internal quotes are left for308 future parsing. Empty values are discarded.309 310 .. code-block:: python311 312 parse_list_header('token, "quoted value"')313 ['token', 'quoted value']314 315 This is the reverse of :func:`dump_header`.316 317 :param value: The header value to parse.318 319 .. versionchanged:: 3.2320 Quotes and escapes are kept if only part of an item is quoted. Empty321 values are omitted. An empty list is returned if the value contains an322 unclosed quoted string.323 """324 items = []325 item = ""326 escape = False327 quote = False328 329 for char in value:330 if escape:331 escape = False332 item += char333 continue334 335 if quote:336 if char == "\\":337 escape = True338 elif char == '"':339 quote = False340 341 item += char342 continue343 344 if char == ",":345 items.append(item)346 item = ""347 continue348 349 if char == '"':350 quote = True351 352 item += char353 354 if quote:355 # invalid, unclosed quoted string356 return []357 358 items.append(item)359 return [360 unquote_header_value(item) for item in (item.strip() for item in items) if item361 ]362 363 364def parse_dict_header(value: str) -> dict[str, str | None]:365 """Parse a list header using :func:`parse_list_header`, then parse each item as a366 ``key=value`` pair.367 368 .. code-block:: python369 370 parse_dict_header('a=b, c="d, e", f')371 {"a": "b", "c": "d, e", "f": None}372 373 This is the reverse of :func:`dump_header`.374 375 If a key does not have a value, it is ``None``.376 377 This handles charsets for values as described in378 `RFC 2231 <https://www.rfc-editor.org/rfc/rfc2231#section-3>`__. Only ASCII, UTF-8,379 and ISO-8859-1 charsets are accepted, otherwise the value remains quoted.380 381 :param value: The header value to parse.382 383 .. versionchanged:: 3.2384 An empty dict is returned if the value contains an unclosed quoted385 string.386 387 .. versionchanged:: 3.0388 Passing bytes is not supported.389 390 .. versionchanged:: 3.0391 The ``cls`` argument is removed.392 393 .. versionchanged:: 2.3394 Added support for ``key*=charset''value`` encoded items.395 396 .. versionchanged:: 0.9397 The ``cls`` argument was added.398 """399 result: dict[str, str | None] = {}400 401 for item in parse_list_header(value):402 key, has_value, value = item.partition("=")403 key = key.strip()404 405 if not key:406 # =value is not valid407 continue408 409 if not has_value:410 result[key] = None411 continue412 413 value = value.strip()414 encoding: str | None = None415 416 if key[-1] == "*":417 # key*=charset''value becomes key=value, where value is percent encoded418 # adapted from parse_options_header, without the continuation handling419 key = key[:-1]420 match = _charset_value_re.match(value)421 422 if match:423 # If there is a charset marker in the value, split it off.424 encoding, value = match.groups()425 encoding = encoding.lower()426 427 # A safe list of encodings. Modern clients should only send ASCII or UTF-8.428 # This list will not be extended further. An invalid encoding will leave the429 # value quoted.430 if encoding in {"ascii", "us-ascii", "utf-8", "iso-8859-1"}:431 # invalid bytes are replaced during unquoting432 value = unquote(value, encoding=encoding)433 434 result[key] = unquote_header_value(value)435 436 return result437 438 439# https://httpwg.org/specs/rfc9110.html#parameter440_parameter_key_re = re.compile(r"([\w!#$%&'*+\-.^`|~]+)=", flags=re.ASCII)441_parameter_token_value_re = re.compile(r"[\w!#$%&'*+\-.^`|~]+", flags=re.ASCII)442# https://www.rfc-editor.org/rfc/rfc2231#section-4443_charset_value_re = re.compile(444 r"""445 ([\w!#$%&*+\-.^`|~]*)' # charset part, could be empty446 [\w!#$%&*+\-.^`|~]*' # don't care about language part, usually empty447 ([\w!#$%&'*+\-.^`|~]+) # one or more token chars with percent encoding448 """,449 re.ASCII | re.VERBOSE,450)451# https://www.rfc-editor.org/rfc/rfc2231#section-3452_continuation_re = re.compile(r"\*(\d+)$", re.ASCII)453 454 455def parse_options_header(value: str | None) -> tuple[str, dict[str, str]]:456 """Parse a header that consists of a value with ``key=value`` parameters separated457 by semicolons ``;``. For example, the ``Content-Type`` header.458 459 .. code-block:: python460 461 parse_options_header("text/html; charset=UTF-8")462 ('text/html', {'charset': 'UTF-8'})463 464 parse_options_header("")465 ("", {})466 467 This is the reverse of :func:`dump_options_header`.468 469 This parses valid parameter parts as described in470 `RFC 9110 <https://httpwg.org/specs/rfc9110.html#parameter>`__. Invalid parts are471 skipped.472 473 This handles continuations and charsets as described in474 `RFC 2231 <https://www.rfc-editor.org/rfc/rfc2231#section-3>`__, although not as475 strictly as the RFC. Only ASCII, UTF-8, and ISO-8859-1 charsets are accepted,476 otherwise the value remains quoted.477 478 Clients may not be consistent in how they handle a quote character within a quoted479 value. The `HTML Standard <https://html.spec.whatwg.org/#multipart-form-data>`__480 replaces it with ``%22`` in multipart form data.481 `RFC 9110 <https://httpwg.org/specs/rfc9110.html#quoted.strings>`__ uses backslash482 escapes in HTTP headers. Both are decoded to the ``"`` character.483 484 Clients may not be consistent in how they handle non-ASCII characters. HTML485 documents must declare ``<meta charset=UTF-8>``, otherwise browsers may replace with486 HTML character references, which can be decoded using :func:`html.unescape`.487 488 :param value: The header value to parse.489 :return: ``(value, options)``, where ``options`` is a dict490 491 .. versionchanged:: 2.3492 Invalid parts, such as keys with no value, quoted keys, and incorrectly quoted493 values, are discarded instead of treating as ``None``.494 495 .. versionchanged:: 2.3496 Only ASCII, UTF-8, and ISO-8859-1 are accepted for charset values.497 498 .. versionchanged:: 2.3499 Escaped quotes in quoted values, like ``%22`` and ``\\"``, are handled.500 501 .. versionchanged:: 2.2502 Option names are always converted to lowercase.503 504 .. versionchanged:: 2.2505 The ``multiple`` parameter was removed.506 507 .. versionchanged:: 0.15508 :rfc:`2231` parameter continuations are handled.509 510 .. versionadded:: 0.5511 """512 if value is None:513 return "", {}514 515 value, _, rest = value.partition(";")516 value = value.strip()517 rest = rest.strip()518 519 if not value or not rest:520 # empty (invalid) value, or value without options521 return value, {}522 523 # Collect all valid key=value parts without processing the value.524 parts: list[tuple[str, str]] = []525 526 while True:527 if (m := _parameter_key_re.match(rest)) is not None:528 pk = m.group(1).lower()529 rest = rest[m.end() :]530 531 # Value may be a token.532 if (m := _parameter_token_value_re.match(rest)) is not None:533 parts.append((pk, m.group()))534 535 # Value may be a quoted string, find the closing quote.536 elif rest[:1] == '"':537 pos = 1538 length = len(rest)539 540 while pos < length:541 if rest[pos : pos + 2] in {"\\\\", '\\"'}:542 # Consume escaped slashes and quotes.543 pos += 2544 elif rest[pos] == '"':545 # Stop at an unescaped quote.546 parts.append((pk, rest[: pos + 1]))547 rest = rest[pos + 1 :]548 break549 else:550 # Consume any other character.551 pos += 1552 553 # Find the next section delimited by `;`, if any.554 if (end := rest.find(";")) == -1:555 break556 557 rest = rest[end + 1 :].lstrip()558 559 options: dict[str, str] = {}560 encoding: str | None = None561 continued_encoding: str | None = None562 563 # For each collected part, process optional charset and continuation,564 # unquote quoted values.565 for pk, pv in parts:566 if pk[-1] == "*":567 # key*=charset''value becomes key=value, where value is percent encoded568 pk = pk[:-1]569 match = _charset_value_re.match(pv)570 571 if match:572 # If there is a valid charset marker in the value, split it off.573 encoding, pv = match.groups()574 # This might be the empty string, handled next.575 encoding = encoding.lower()576 577 # No charset marker, or marker with empty charset value.578 if not encoding:579 encoding = continued_encoding580 581 # A safe list of encodings. Modern clients should only send ASCII or UTF-8.582 # This list will not be extended further. An invalid encoding will leave the583 # value quoted.584 if encoding in {"ascii", "us-ascii", "utf-8", "iso-8859-1"}:585 # Continuation parts don't require their own charset marker. This is586 # looser than the RFC, it will persist across different keys and allows587 # changing the charset during a continuation. But this implementation is588 # much simpler than tracking the full state.589 continued_encoding = encoding590 # invalid bytes are replaced during unquoting591 pv = unquote(pv, encoding=encoding)592 593 # Remove quotes. At this point the value cannot be empty or a single quote.594 if pv[0] == pv[-1] == '"':595 # HTTP headers use slash, multipart form data uses percent596 pv = pv[1:-1].replace("\\\\", "\\").replace('\\"', '"').replace("%22", '"')597 598 match = _continuation_re.search(pk)599 600 if match:601 # key*0=a; key*1=b becomes key=ab602 pk = pk[: match.start()]603 options[pk] = options.get(pk, "") + pv604 else:605 options[pk] = pv606 607 return value, options608 609 610_q_value_re = re.compile(r"-?\d+(\.\d+)?", re.ASCII)611_TAnyAccept = t.TypeVar("_TAnyAccept", bound="ds.Accept")612 613 614@t.overload615def parse_accept_header(value: str | None) -> ds.Accept: ...616 617 618@t.overload619def parse_accept_header(value: str | None, cls: type[_TAnyAccept]) -> _TAnyAccept: ...620 621 622def parse_accept_header(623 value: str | None, cls: type[_TAnyAccept] | None = None624) -> _TAnyAccept:625 """Parse an ``Accept`` header according to626 `RFC 9110 <https://httpwg.org/specs/rfc9110.html#field.accept>`__.627 628 Returns an :class:`.Accept` instance, which can sort and inspect items based on629 their quality parameter. When parsing ``Accept-Charset``, ``Accept-Encoding``, or630 ``Accept-Language``, pass the appropriate :class:`.Accept` subclass.631 632 :param value: The header value to parse.633 :param cls: The :class:`.Accept` class to wrap the result in.634 :return: An instance of ``cls``.635 636 .. versionchanged:: 2.3637 Parse according to RFC 9110. Items with invalid ``q`` values are skipped.638 """639 if cls is None:640 cls = t.cast(type[_TAnyAccept], ds.Accept)641 642 if not value:643 return cls(None)644 645 result = []646 647 for item in parse_list_header(value):648 item, options = parse_options_header(item)649 650 if "q" in options:651 # pop q, remaining options are reconstructed652 q_str = options.pop("q").strip()653 654 if _q_value_re.fullmatch(q_str) is None:655 # ignore an invalid q656 continue657 658 q = float(q_str)659 660 if q < 0 or q > 1:661 # ignore an invalid q662 continue663 else:664 q = 1665 666 if options:667 # reconstruct the media type with any options668 item = dump_options_header(item, options)669 670 result.append((item, q))671 672 return cls(result)673 674 675_TAnyCC = t.TypeVar("_TAnyCC", bound="ds.cache_control._CacheControl")676 677 678@t.overload679def parse_cache_control_header(680 value: str | None,681 on_update: t.Callable[[ds.cache_control._CacheControl], None] | None = None,682) -> ds.RequestCacheControl: ...683 684 685@t.overload686def parse_cache_control_header(687 value: str | None,688 on_update: t.Callable[[ds.cache_control._CacheControl], None] | None = None,689 cls: type[_TAnyCC] = ...,690) -> _TAnyCC: ...691 692 693def parse_cache_control_header(694 value: str | None,695 on_update: t.Callable[[ds.cache_control._CacheControl], None] | None = None,696 cls: type[_TAnyCC] | None = None,697) -> _TAnyCC:698 """Parse a cache control header. The RFC differs between response and699 request cache control, this method does not. It's your responsibility700 to not use the wrong control statements.701 702 .. versionadded:: 0.5703 The `cls` was added. If not specified an immutable704 :class:`~werkzeug.datastructures.RequestCacheControl` is returned.705 706 :param value: a cache control header to be parsed.707 :param on_update: an optional callable that is called every time a value708 on the :class:`~werkzeug.datastructures.CacheControl`709 object is changed.710 :param cls: the class for the returned object. By default711 :class:`~werkzeug.datastructures.RequestCacheControl` is used.712 :return: a `cls` object.713 """714 if cls is None:715 cls = t.cast("type[_TAnyCC]", ds.RequestCacheControl)716 717 if not value:718 return cls((), on_update)719 720 return cls(parse_dict_header(value), on_update)721 722 723_TAnyCSP = t.TypeVar("_TAnyCSP", bound="ds.ContentSecurityPolicy")724 725 726@t.overload727def parse_csp_header(728 value: str | None,729 on_update: t.Callable[[ds.ContentSecurityPolicy], None] | None = None,730) -> ds.ContentSecurityPolicy: ...731 732 733@t.overload734def parse_csp_header(735 value: str | None,736 on_update: t.Callable[[ds.ContentSecurityPolicy], None] | None = None,737 cls: type[_TAnyCSP] = ...,738) -> _TAnyCSP: ...739 740 741def parse_csp_header(742 value: str | None,743 on_update: t.Callable[[ds.ContentSecurityPolicy], None] | None = None,744 cls: type[_TAnyCSP] | None = None,745) -> _TAnyCSP:746 """Parse a Content Security Policy header.747 748 .. versionadded:: 1.0.0749 Support for Content Security Policy headers was added.750 751 :param value: a csp header to be parsed.752 :param on_update: an optional callable that is called every time a value753 on the object is changed.754 :param cls: the class for the returned object. By default755 :class:`~werkzeug.datastructures.ContentSecurityPolicy` is used.756 :return: a `cls` object.757 """758 if cls is None:759 cls = t.cast("type[_TAnyCSP]", ds.ContentSecurityPolicy)760 761 if value is None:762 return cls((), on_update)763 764 items = []765 766 for policy in value.split(";"):767 policy = policy.strip()768 769 # Ignore badly formatted policies (no space)770 if " " in policy:771 directive, value = policy.strip().split(" ", 1)772 items.append((directive.strip(), value.strip()))773 774 return cls(items, on_update)775 776 777def parse_set_header(778 value: str | None,779 on_update: t.Callable[[ds.HeaderSet], None] | None = None,780) -> ds.HeaderSet:781 """Parse a set-like header and return a782 :class:`~werkzeug.datastructures.HeaderSet` object:783 784 >>> hs = parse_set_header('token, "quoted value"')785 786 The return value is an object that treats the items case-insensitively787 and keeps the order of the items:788 789 >>> 'TOKEN' in hs790 True791 >>> hs.index('quoted value')792 1793 >>> hs794 HeaderSet(['token', 'quoted value'])795 796 To create a header from the :class:`HeaderSet` again, use the797 :func:`dump_header` function.798 799 :param value: a set header to be parsed.800 :param on_update: an optional callable that is called every time a801 value on the :class:`~werkzeug.datastructures.HeaderSet`802 object is changed.803 :return: a :class:`~werkzeug.datastructures.HeaderSet`804 """805 if not value:806 return ds.HeaderSet(None, on_update)807 return ds.HeaderSet(parse_list_header(value), on_update)808 809 810def parse_if_range_header(value: str | None) -> ds.IfRange:811 """Parses an if-range header which can be an etag or a date. Returns812 a :class:`~werkzeug.datastructures.IfRange` object.813 814 .. versionchanged:: 2.0815 If the value represents a datetime, it is timezone-aware.816 817 .. versionadded:: 0.7818 """819 if not value:820 return ds.IfRange()821 date = parse_date(value)822 if date is not None:823 return ds.IfRange(date=date)824 # drop weakness information825 return ds.IfRange(unquote_etag(value)[0])826 827 828def parse_range_header(829 value: str | None, make_inclusive: bool = True830) -> ds.Range | None:831 """Parses a range header into a :class:`~werkzeug.datastructures.Range`832 object. If the header is missing or malformed `None` is returned.833 `ranges` is a list of ``(start, stop)`` tuples where the ranges are834 non-inclusive.835 836 .. versionadded:: 0.7837 """838 if not value or "=" not in value:839 return None840 841 ranges = []842 last_end = 0843 units, rng = value.split("=", 1)844 units = units.strip().lower()845 846 for item in rng.split(","):847 item = item.strip()848 if "-" not in item:849 return None850 if item.startswith("-"):851 if last_end < 0:852 return None853 try:854 begin = _plain_int(item)855 except ValueError:856 return None857 end = None858 last_end = -1859 elif "-" in item:860 begin_str, end_str = item.split("-", 1)861 begin_str = begin_str.strip()862 end_str = end_str.strip()863 864 try:865 begin = _plain_int(begin_str)866 except ValueError:867 return None868 869 if begin < last_end or last_end < 0:870 return None871 if end_str:872 try:873 end = _plain_int(end_str) + 1874 except ValueError:875 return None876 877 if begin >= end:878 return None879 else:880 end = None881 last_end = end if end is not None else -1882 ranges.append((begin, end))883 884 return ds.Range(units, ranges)885 886 887def parse_content_range_header(888 value: str | None,889 on_update: t.Callable[[ds.ContentRange], None] | None = None,890) -> ds.ContentRange | None:891 """Parses a range header into a892 :class:`~werkzeug.datastructures.ContentRange` object or `None` if893 parsing is not possible.894 895 .. versionadded:: 0.7896 897 :param value: a content range header to be parsed.898 :param on_update: an optional callable that is called every time a value899 on the :class:`~werkzeug.datastructures.ContentRange`900 object is changed.901 """902 if value is None:903 return None904 try:905 units, rangedef = (value or "").strip().split(None, 1)906 except ValueError:907 return None908 909 if "/" not in rangedef:910 return None911 rng, length_str = rangedef.split("/", 1)912 if length_str == "*":913 length = None914 else:915 try:916 length = _plain_int(length_str)917 except ValueError:918 return None919 920 if rng == "*":921 if not is_byte_range_valid(None, None, length):922 return None923 924 return ds.ContentRange(units, None, None, length, on_update=on_update)925 elif "-" not in rng:926 return None927 928 start_str, stop_str = rng.split("-", 1)929 try:930 start = _plain_int(start_str)931 stop = _plain_int(stop_str) + 1932 except ValueError:933 return None934 935 if is_byte_range_valid(start, stop, length):936 return ds.ContentRange(units, start, stop, length, on_update=on_update)937 938 return None939 940 941def quote_etag(etag: str, weak: bool = False) -> str:942 """Quote an etag.943 944 :param etag: the etag to quote.945 :param weak: set to `True` to tag it "weak".946 """947 if '"' in etag:948 raise ValueError("invalid etag")949 etag = f'"{etag}"'950 if weak:951 etag = f"W/{etag}"952 return etag953 954 955@t.overload956def unquote_etag(etag: str) -> tuple[str, bool]: ...957@t.overload958def unquote_etag(etag: None) -> tuple[None, None]: ...959def unquote_etag(960 etag: str | None,961) -> tuple[str, bool] | tuple[None, None]:962 """Unquote a single etag:963 964 >>> unquote_etag('W/"bar"')965 ('bar', True)966 >>> unquote_etag('"bar"')967 ('bar', False)968 969 :param etag: the etag identifier to unquote.970 :return: a ``(etag, weak)`` tuple.971 """972 if not etag:973 return None, None974 etag = etag.strip()975 weak = False976 if etag.startswith(("W/", "w/")):977 weak = True978 etag = etag[2:]979 if etag[:1] == etag[-1:] == '"':980 etag = etag[1:-1]981 return etag, weak982 983 984def parse_etags(value: str | None) -> ds.ETags:985 """Parse an etag header.986 987 :param value: the tag header to parse988 :return: an :class:`~werkzeug.datastructures.ETags` object.989 """990 if not value:991 return ds.ETags()992 strong = []993 weak = []994 end = len(value)995 pos = 0996 while pos < end:997 match = _etag_re.match(value, pos)998 if match is None:999 break1000 is_weak, quoted, raw = match.groups()1001 if raw == "*":1002 return ds.ETags(star_tag=True)1003 elif quoted:1004 raw = quoted1005 if is_weak:1006 weak.append(raw)1007 else:1008 strong.append(raw)1009 pos = match.end()1010 return ds.ETags(strong, weak)1011 1012 1013def generate_etag(data: bytes) -> str:1014 """Generate an etag for some data.1015 1016 .. versionchanged:: 2.01017 Use SHA-1. MD5 may not be available in some environments.1018 """1019 return sha1(data).hexdigest()1020 1021 1022def parse_date(value: str | None) -> datetime | None:1023 """Parse an :rfc:`2822` date into a timezone-aware1024 :class:`datetime.datetime` object, or ``None`` if parsing fails.1025 1026 This is a wrapper for :func:`email.utils.parsedate_to_datetime`. It1027 returns ``None`` if parsing fails instead of raising an exception,1028 and always returns a timezone-aware datetime object. If the string1029 doesn't have timezone information, it is assumed to be UTC.1030 1031 :param value: A string with a supported date format.1032 1033 .. versionchanged:: 2.01034 Return a timezone-aware datetime object. Use1035 ``email.utils.parsedate_to_datetime``.1036 """1037 if value is None:1038 return None1039 1040 try:1041 dt = email.utils.parsedate_to_datetime(value)1042 except (TypeError, ValueError):1043 return None1044 1045 if dt.tzinfo is None:1046 return dt.replace(tzinfo=timezone.utc)1047 1048 return dt1049 1050 1051def http_date(1052 timestamp: datetime | date | int | float | struct_time | None = None,1053) -> str:1054 """Format a datetime object or timestamp into an :rfc:`2822` date1055 string.1056 1057 This is a wrapper for :func:`email.utils.format_datetime`. It1058 assumes naive datetime objects are in UTC instead of raising an1059 exception.1060 1061 :param timestamp: The datetime or timestamp to format. Defaults to1062 the current time.1063 1064 .. versionchanged:: 2.01065 Use ``email.utils.format_datetime``. Accept ``date`` objects.1066 """1067 if isinstance(timestamp, date):1068 if not isinstance(timestamp, datetime):1069 # Assume plain date is midnight UTC.1070 timestamp = datetime.combine(timestamp, time(), tzinfo=timezone.utc)1071 else:1072 # Ensure datetime is timezone-aware.1073 timestamp = _dt_as_utc(timestamp)1074 1075 return email.utils.format_datetime(timestamp, usegmt=True)1076 1077 if isinstance(timestamp, struct_time):1078 timestamp = mktime(timestamp)1079 1080 return email.utils.formatdate(timestamp, usegmt=True)1081 1082 1083def parse_age(value: str | None = None) -> timedelta | None:1084 """Parses a base-10 integer count of seconds into a timedelta.1085 1086 If parsing fails, the return value is `None`.1087 1088 :param value: a string consisting of an integer represented in base-101089 :return: a :class:`datetime.timedelta` object or `None`.1090 """1091 if not value:1092 return None1093 try:1094 seconds = int(value)1095 except ValueError:1096 return None1097 if seconds < 0:1098 return None1099 try:1100 return timedelta(seconds=seconds)1101 except OverflowError:1102 return None1103 1104 1105def dump_age(age: timedelta | int | None = None) -> str | None:1106 """Formats the duration as a base-10 integer.1107 1108 :param age: should be an integer number of seconds,1109 a :class:`datetime.timedelta` object, or,1110 if the age is unknown, `None` (default).1111 """1112 if age is None:1113 return None1114 if isinstance(age, timedelta):1115 age = int(age.total_seconds())1116 else:1117 age = int(age)1118 1119 if age < 0:1120 raise ValueError("age cannot be negative")1121 1122 return str(age)1123 1124 1125def is_resource_modified(1126 environ: WSGIEnvironment,1127 etag: str | None = None,1128 data: bytes | None = None,1129 last_modified: datetime | str | None = None,1130 ignore_if_range: bool = True,1131) -> bool:1132 """Convenience method for conditional requests.1133 1134 :param environ: the WSGI environment of the request to be checked.1135 :param etag: the etag for the response for comparison.1136 :param data: or alternatively the data of the response to automatically1137 generate an etag using :func:`generate_etag`.1138 :param last_modified: an optional date of the last modification.1139 :param ignore_if_range: If `False`, `If-Range` header will be taken into1140 account.1141 :return: `True` if the resource was modified, otherwise `False`.1142 1143 .. versionchanged:: 2.01144 SHA-1 is used to generate an etag value for the data. MD5 may1145 not be available in some environments.1146 1147 .. versionchanged:: 1.0.01148 The check is run for methods other than ``GET`` and ``HEAD``.1149 """1150 return _sansio_http.is_resource_modified(1151 http_range=environ.get("HTTP_RANGE"),1152 http_if_range=environ.get("HTTP_IF_RANGE"),1153 http_if_modified_since=environ.get("HTTP_IF_MODIFIED_SINCE"),1154 http_if_none_match=environ.get("HTTP_IF_NONE_MATCH"),1155 http_if_match=environ.get("HTTP_IF_MATCH"),1156 etag=etag,1157 data=data,1158 last_modified=last_modified,1159 ignore_if_range=ignore_if_range,1160 )1161 1162 1163def remove_entity_headers(1164 headers: ds.Headers | list[tuple[str, str]],1165 allowed: t.Iterable[str] = ("expires", "content-location"),1166) -> None:1167 """Remove all entity headers from a list or :class:`Headers` object. This1168 operation works in-place. `Expires` and `Content-Location` headers are1169 by default not removed. The reason for this is :rfc:`2616` section1170 10.3.5 which specifies some entity headers that should be sent.1171 1172 .. versionchanged:: 0.51173 added `allowed` parameter.1174 1175 :param headers: a list or :class:`Headers` object.1176 :param allowed: a list of headers that should still be allowed even though1177 they are entity headers.1178 """1179 allowed = {x.lower() for x in allowed}1180 headers[:] = [1181 (key, value)1182 for key, value in headers1183 if not is_entity_header(key) or key.lower() in allowed1184 ]1185 1186 1187def remove_hop_by_hop_headers(headers: ds.Headers | list[tuple[str, str]]) -> None:1188 """Remove all HTTP/1.1 "Hop-by-Hop" headers from a list or1189 :class:`Headers` object. This operation works in-place.1190 1191 .. versionadded:: 0.51192 1193 :param headers: a list or :class:`Headers` object.1194 """1195 headers[:] = [1196 (key, value) for key, value in headers if not is_hop_by_hop_header(key)1197 ]1198 1199 1200def is_entity_header(header: str) -> bool: