Source code for action0.req.headers

"""The :py:class:`Headers` mapping and the known header field names (:py:class:`Header`)."""

from enum import StrEnum
from typing import Iterable
from typing import Iterator
from typing import Mapping
from typing import MutableMapping
from typing import Union
from typing import cast

HeaderValue = Union[str, int, float, bool]
"""A single header value; non-strings are coerced to strings on the way in
(bools become the web-style ``"true"`` / ``"false"``)."""

# Mapping instead of dict so callers may pass any dict-ish type and, unlike
# dict, Mapping is covariant in its value type (a dict[str, str] works too)
HeaderTypes = Union[
    Iterable[tuple[str, Union[HeaderValue, Iterable[HeaderValue]]]],
    Mapping[str, Union[HeaderValue, Iterable[HeaderValue]]],
    str,
]
"""Everything that can initialize a :py:class:`Headers` instance: a raw
header block, a mapping, or an iterable of name/value(s) tuples."""


def _coerce_value(value: HeaderValue) -> str:
    """
    Coerce a single header value to its string representation.

    :param value: the value to coerce
    :return: the value as a string, bools as "true" / "false"
    """
    # bool before the plain str() fallback: bools use the web convention
    if isinstance(value, bool):
        return "true" if value else "false"
    if isinstance(value, str):
        return value
    return str(value)


def _coerce_values(value_s: Union[HeaderValue, Iterable[HeaderValue]]) -> list[str]:
    """
    Coerce a single value or an iterable of values to a list of strings.

    :param value_s: a single value or an iterable of values
    :return: all values as a list of strings
    """
    if isinstance(value_s, (str, int, float, bool)):
        return [_coerce_value(value_s)]
    return [_coerce_value(value_) for value_ in value_s]


[docs] class Headers(MutableMapping[str, str]): r""" An ordered, case-insensitive, multi-value aware mapping of HTTP header fields. Internally it is a list of ``(name, value)`` lines: the order and casing of the representation are preserved exactly, while every lookup matches field names case-insensitively (per :rfc:`9110` — ``headers["content-type"]`` and ``headers["Content-Type"]`` hit the same field). Names are never normalized beyond that; in particular an underscore is not treated as a dash. Headers implements :py:class:`typing.MutableMapping`: the mapping view (``headers[name]``, :py:meth:`get`, ``items()``, ...) works with a single value per name — the last one, like :py:meth:`singles`. Multi-value access is available through :py:meth:`get_all`, :py:meth:`get_values`, :py:meth:`add`, :py:meth:`as_dict` and :py:meth:`as_lines`. Example:: >>> headers = Headers({"Content-Type": "text/html"}) >>> headers.add("Set-Cookie", "a=1") >>> headers.add("set-cookie", "b=2") >>> headers["content-type"] 'text/html' >>> headers.get_all("Set-Cookie") ['a=1', 'b=2'] >>> len(headers), "SET-COOKIE" in headers (2, True) >>> headers.as_str() 'Content-Type: text/html\r\nSet-Cookie: a=1\r\nset-cookie: b=2' ``repr()`` (and thus ``str()``) redacts the values of the :py:attr:`secret_names` fields; only :py:meth:`as_str` renders them: >>> headers["Authorization"] = "Bearer secret-token" >>> headers Headers(Content-Type: text/html, Set-Cookie: ***, set-cookie: ***, Authorization: ***) """ secret_names: frozenset[str] = frozenset( { "authorization", "proxy-authorization", "cookie", "set-cookie", "x-api-key", "x-csrf-token", } ) """The (casefolded) names of the fields whose values carry secrets and are shown as ``***`` by ``repr()``. Override on a subclass or instance to redact more (or fewer) fields.""" def __init__(self, headers: Union[HeaderTypes, None] = None) -> None: r""" :param headers: the initial header lines, either as a raw header block (lines of ``"Name: value"`` separated by ``"\r\n"`` or ``"\n"``; blank lines are skipped, obsolete line folding is not supported), another Headers instance whose lines are copied, or as a list of tuples or a dictionary. The values can be single values or lists of values (one line each); non-string values are coerced to strings (bools become "true" / "false"). :raises ValueError: if a line of a raw header block has no ``":"`` """ self._lines: list[tuple[str, str]] = [] if isinstance(headers, Headers): self._lines = list(headers._lines) elif isinstance(headers, str): for line in headers.splitlines(): if not line.strip(): continue name, sep, value = line.partition(":") if not sep or not name.strip(): raise ValueError(f"invalid header line: {line!r}") self.add(name.strip(), value.strip(" \t")) elif isinstance(headers, Mapping): # cast: the type checker cannot fully rule out the Iterable-of-tuples # union member here because a Mapping is itself an Iterable mapping = cast("Mapping[str, Union[HeaderValue, Iterable[HeaderValue]]]", headers) for name, value_s in mapping.items(): self.add(name, value_s) elif isinstance(headers, Iterable): for name, value_s in headers: self.add(name, value_s)
[docs] def __getitem__(self, name: str) -> str: """ The single value of the field; if the field has multiple lines, the value of the last one — like :py:meth:`singles`. Use :py:meth:`get_all` for all values. :param name: the field name (case-insensitive) :return: the (last) value of the field :raises KeyError: if the field does not exist """ folded = name.casefold() for line_name, value in reversed(self._lines): if line_name.casefold() == folded: return value raise KeyError(name)
[docs] def __setitem__(self, name: str, value: Union[HeaderValue, Iterable[HeaderValue]]) -> None: """ Replace all lines of the field, same as :py:meth:`set`. :param name: the field name (case-insensitive) :param value: a single value or a list of values (one line each) """ self.set(name, value)
[docs] def __delitem__(self, name: str) -> None: """ Remove the field with all its lines. :param name: the field name (case-insensitive) :raises KeyError: if the field does not exist """ if not self.remove(name): raise KeyError(name)
[docs] def __iter__(self) -> Iterator[str]: """ :return: an iterator over the distinct field names in the order of their first line, each in the casing of that first line """ seen: set[str] = set() for name, _ in self._lines: folded = name.casefold() if folded not in seen: seen.add(folded) yield name
[docs] def __len__(self) -> int: """ :return: the number of distinct field names """ return len({name.casefold() for name, _ in self._lines})
[docs] def __contains__(self, name: object) -> bool: """ :param name: the field name (case-insensitive) :return: whether a field with this name exists """ if not isinstance(name, str): return False folded = name.casefold() return any(line_name.casefold() == folded for line_name, _ in self._lines)
[docs] def get_all(self, name: str) -> list[str]: """ The values of all lines of the field, in representation order; use ``headers[name]`` or :py:meth:`get` for the single-value view and :py:meth:`get_values` for the comma-split element view. :param name: the field name (case-insensitive) :return: the values as a list, an empty list if the field does not exist """ folded = name.casefold() return [value for line_name, value in self._lines if line_name.casefold() == folded]
[docs] def get_values(self, name: str) -> list[str]: """ The elements of all lines of the field: like :py:meth:`get_all`, but each line is additionally split on ``","`` (:rfc:`9110` list syntax) with whitespace stripped and empty elements dropped. WARNING: ``Set-Cookie`` does not use the list syntax — its values may contain literal commas (e.g. in an ``Expires`` attribute), so use :py:meth:`get_all` for it. :param name: the field name (case-insensitive) :return: the elements as a list, an empty list if the field does not exist """ return [ element.strip(" \t") for line in self.get_all(name) for element in line.split(",") if element.strip(" \t") ]
[docs] def add(self, name: str, value: Union[HeaderValue, Iterable[HeaderValue]]) -> None: """ Append a line for each given value at the end, keeping existing lines of the field. :param name: the field name to add (stored in the given casing) :param value: the value or list of values to add (one line each) """ self._lines.extend((name, value_) for value_ in _coerce_values(value))
[docs] def remove( self, name: str, value: Union[HeaderValue, Iterable[HeaderValue], None] = None ) -> list[str]: """ If only a name is given all lines of this field are removed. If a value or a list of values is given only the matching lines are removed. :param name: the name of the field to remove (or from which lines are to be removed), case-insensitive :param value: if given, only lines with matching value(s) are to be removed, not the entire field :return: a list of removed values """ folded = name.casefold() value_list = None if value is None else _coerce_values(value) kept: list[tuple[str, str]] = [] removed: list[str] = [] for line_name, line_value in self._lines: if line_name.casefold() == folded and (value_list is None or line_value in value_list): removed.append(line_value) else: kept.append((line_name, line_value)) self._lines = kept return removed
[docs] def set(self, name: str, value: Union[HeaderValue, Iterable[HeaderValue]]) -> None: """ Replace all lines of the field with the value(s) given, at the position of the field's first line (new fields are appended at the end). Setting an empty list of values removes the field. :param name: the field name (matched case-insensitively, stored in the given casing) :param value: a single value or a list of values (one line each) """ values = _coerce_values(value) if not values: self.remove(name) return folded = name.casefold() new_lines: list[tuple[str, str]] = [] inserted = False for line_name, line_value in self._lines: if line_name.casefold() == folded: # replace the first line of the field with the new lines, # drop the others if not inserted: new_lines.extend((name, value_) for value_ in values) inserted = True else: new_lines.append((line_name, line_value)) if not inserted: new_lines.extend((name, value_) for value_ in values) self._lines = new_lines
# narrower than the MutableMapping contract on purpose: update() accepts # the same input forms as the constructor (e.g. a raw header block) and # takes no **kwargs — header names (e.g. "Content-Type") aren't Python # identifiers
[docs] def update( # type: ignore[override] # ty: ignore[invalid-method-override] self, headers: Union[HeaderTypes, None] = None ) -> None: """ Merge the given headers into this instance: lines of fields that already exist are replaced (like ``dict.update``, at the position of the field's first line), other fields are appended. Accepts the same forms as the constructor (raw header block, mapping, iterable of tuples, Headers instance). :param headers: the headers to merge in """ if headers is None: return other = headers if isinstance(headers, Headers) else Headers(headers) for name in other: self.set(name, other.get_all(name))
[docs] def clear(self) -> list[tuple[str, str]]: # type: ignore[override] # ty: ignore[invalid-method-override] """ Remove all fields, returns the removed lines (unlike ``MutableMapping.clear`` which returns ``None``). :return: the removed lines as name/value tuples """ old = self._lines self._lines = [] return old
[docs] def copy(self) -> "Headers": """ :return: a new Headers instance with a copy of the lines """ return Headers(self)
[docs] def sort(self) -> None: """ Sort the lines in place by their (casefolded) field name. The sort is stable: lines of the same field keep their relative order — unlike :py:meth:`~action0.url.params.Params.sort`, values are never reordered because their order can be significant (e.g. ``Set-Cookie``). """ self._lines.sort(key=lambda line: line[0].casefold())
[docs] def as_str(self, separator: str = "\r\n") -> str: r""" The wire representation of the header lines, in order, with the original casing — secret values are NOT redacted here, unlike in ``repr()``. :param separator: the string joining the lines (no trailing one) :return: the header block, e.g. ``"Host: example.com\r\nAccept: */*"`` """ return separator.join(f"{name}: {value}" for name, value in self._lines)
[docs] def as_lines(self) -> list[tuple[str, str]]: """ :return: a copy of the representation: the header lines as name/value tuples, in order, with the original casing """ return list(self._lines)
[docs] def as_dict(self) -> dict[str, list[str]]: """ :return: the fields as a dictionary with the field names as keys (in the casing of each field's first line) and the values as lists of strings """ result: dict[str, list[str]] = {} canonical: dict[str, str] = {} for name, value in self._lines: key = canonical.setdefault(name.casefold(), name) result.setdefault(key, []).append(value) return result
[docs] def singles(self) -> dict[str, str]: """ For those who are really sure that each field has only one line and do not want to bother with the lists for the values, this method will return only the last value for each field. WARNING: be aware, if the field has multiple lines, only one of those will be returned for the name! :return: a dictionary with a single value for each field name """ return {name: values[-1] for name, values in self.as_dict().items()}
def _folded(self) -> dict[str, list[str]]: """ :return: the fields keyed by casefolded name for order- and casing-insensitive comparison """ result: dict[str, list[str]] = {} for name, value in self._lines: result.setdefault(name.casefold(), []).append(value) return result
[docs] def __eq__(self, other: object) -> bool: """ Headers are equal when they hold the same fields with the same values in the same per-field order; the order of different fields and the casing of the names don't matter. Plain mappings are converted to Headers before comparing. :param other: the Headers instance or mapping to compare with :return: whether the headers are equal """ if isinstance(other, Headers): return self._folded() == other._folded() if isinstance(other, Mapping): # cast: the values of an arbitrary mapping are unknown to the # type checker; non-string values are coerced like everywhere else return self._folded() == Headers(cast("HeaderTypes", other))._folded() return NotImplemented
[docs] def __repr__(self) -> str: """ :return: the header lines in order, with the values of the :py:attr:`secret_names` fields redacted as ``***``. ``str()`` falls back to this representation, so only :py:meth:`as_str` ever renders the secret values. """ rendered = ", ".join( f"{name}: {'***' if name.casefold() in self.secret_names else value}" for name, value in self._lines ) return f"{self.__class__.__name__}({rendered})"