Source code for action0.url.params

from typing import Iterable
from typing import Iterator
from typing import Literal
from typing import Mapping
from typing import MutableMapping
from typing import Union
from typing import cast
from urllib.parse import parse_qsl
from urllib.parse import urlencode

ParamValue = Union[str, int, float, bool]
"""A single parameter 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)
ParamTypes = Union[
    Iterable[tuple[str, Union[ParamValue, Iterable[ParamValue]]]],
    Mapping[str, Union[ParamValue, Iterable[ParamValue]]],
    str,
]
"""Everything that can initialize a :py:class:`Params` instance: a query
string, a mapping, or an iterable of name/value(s) tuples."""


def _coerce_value(value: ParamValue) -> str:
    """
    Coerce a single parameter 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[ParamValue, Iterable[ParamValue]]) -> 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 Params(MutableMapping[str, str]): """ Allows easy manipulation of URL query parameters and URL path parameters, it supports single and multiple values for a key. Internally the parameters are an ordered list of ``(key, value)`` pairs, so the full representation order — including values of one key interleaved with other keys — round-trips losslessly: ``Params("a=1&b=2&a=3").as_str()`` is ``"a=1&b=2&a=3"`` again. The grouped views (:py:meth:`as_dict`, :py:meth:`as_tuples`) collect the values per key instead. Params implements :py:class:`typing.MutableMapping`: the mapping view (``params[key]``, :py:meth:`get`, ``items()``, ``values()``, ...) works with a single value per key — the last one, like :py:meth:`singles`. Multi-value access is available through :py:meth:`get_all`, :py:meth:`add`, :py:meth:`as_dict` and :py:meth:`as_tuples`. Example:: >>> params = Params("b=2&a=1") >>> params["a"] '1' >>> params.add("a", 3) >>> params.get_all("a") ['1', '3'] >>> params.as_str() 'b=2&a=1&a=3' >>> params.as_str(sort=True) 'a=1&a=3&b=2' """ def __init__( self, params: Union[ParamTypes, None] = None, separator: Literal["&", ";"] = "&" ) -> None: """ :param params: the initial key-value(s) to set, either as a string which will be parsed using parse_qsl, another Params instance whose pairs are copied, or as a list of tuples or a dictionary. The values can be single values or lists of values; non-string values are coerced to strings (bools become "true" / "false"). Unlike parse_qsl's default, blank values are kept ("a=&b=1" keeps "a"), so parsing and re-rendering is lossless. :param separator: either a '&' or a ';' to separate the key-value pairs in the string representation (also used when copying another Params instance) """ self._pairs: list[tuple[str, str]] = [] self.separator: Literal["&", ";"] = separator if isinstance(params, Params): self._pairs = list(params._pairs) elif isinstance(params, str): self._pairs = parse_qsl(params, separator=self.separator, keep_blank_values=True) elif isinstance(params, 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[ParamValue, Iterable[ParamValue]]]", params) for key, value_s in mapping.items(): self.add(key, value_s) elif isinstance(params, Iterable): for key, value_s in params: self.add(key, value_s)
[docs] def __getitem__(self, key: str) -> str: """ The single value of the parameter; if the parameter has multiple values, the last one — like :py:meth:`singles`. Use :py:meth:`get_all` for all values. :param key: the parameter name :return: the (last) value of the parameter :raises KeyError: if the parameter does not exist """ for pair_key, value in reversed(self._pairs): if pair_key == key: return value raise KeyError(key)
[docs] def __setitem__(self, key: str, value: Union[ParamValue, Iterable[ParamValue]]) -> None: """ Replace all values of the parameter, same as :py:meth:`set`. :param key: the parameter name :param value: a single value or a list of values """ self.set(key, value)
[docs] def __delitem__(self, key: str) -> None: """ Remove the parameter with all its values. :param key: the parameter name :raises KeyError: if the parameter does not exist """ if not self.remove(key): raise KeyError(key)
[docs] def __iter__(self) -> Iterator[str]: """ :return: an iterator over the distinct parameter names in the order of their first pair """ seen: set[str] = set() for key, _ in self._pairs: if key not in seen: seen.add(key) yield key
[docs] def __len__(self) -> int: """ :return: the number of distinct parameter names """ return len({key for key, _ in self._pairs})
[docs] def __contains__(self, key: object) -> bool: """ :param key: the parameter name :return: whether a parameter with this name exists """ return any(pair_key == key for pair_key, _ in self._pairs)
[docs] def get_all(self, key: str) -> list[str]: """ All values of the parameter in representation order; use ``params[key]`` or :py:meth:`get` for the single-value view. :param key: the parameter name :return: the values as a list, an empty list if the parameter does not exist """ return [value for pair_key, value in self._pairs if pair_key == key]
[docs] def add(self, key: str, value: Union[ParamValue, Iterable[ParamValue]]) -> None: """ Add a parameter with a single value or multiple values, appended at the end of the representation. If it is a single value, the query string equivalent would be something like "foo=bar". If it is a list of values, the query string equivalent would be something like "foo=bar&foo=baz&foo=abc". Existing values are kept (in place). :param key: the parameter name to add :param value: the parameter value or list of values to add """ self._pairs.extend((key, value_) for value_ in _coerce_values(value))
[docs] def remove( self, key: str, value: Union[ParamValue, Iterable[ParamValue], None] = None ) -> list[str]: """ If only a key is given all values with this name are removed. If a value or a list of values is given only the matching values are removed. :param key: the name of the parameter to remove (or from which values are to be removed) :param value: if given, only matching value(s) are to be removed not the entire parameter :return: a list of removed values """ value_list = None if value is None else _coerce_values(value) kept: list[tuple[str, str]] = [] removed: list[str] = [] for pair_key, pair_value in self._pairs: if pair_key == key and (value_list is None or pair_value in value_list): removed.append(pair_value) else: kept.append((pair_key, pair_value)) self._pairs = kept return removed
[docs] def set(self, key: str, value: Union[ParamValue, Iterable[ParamValue]]) -> None: """ Replace all value(s) of the key with the value(s) given, at the position of the key's first pair (new keys are appended at the end). Setting an empty list of values removes the key. :param key: the key to set values for :param value: a single value or a list of values to set """ values = _coerce_values(value) if not values: self.remove(key) return new_pairs: list[tuple[str, str]] = [] inserted = False for pair_key, pair_value in self._pairs: if pair_key == key: # replace the key's first pair with the new values, drop the # other pairs of the key if not inserted: new_pairs.extend((key, value_) for value_ in values) inserted = True else: new_pairs.append((pair_key, pair_value)) if not inserted: new_pairs.extend((key, value_) for value_ in values) self._pairs = new_pairs
# narrower than the MutableMapping contract on purpose: update() accepts # the same input forms as the constructor (e.g. a query string) instead of # arbitrary keys()/__getitem__ objects
[docs] def update( # type: ignore[override] # ty: ignore[invalid-method-override] self, params: Union[ParamTypes, None] = None, **kwargs: Union[ParamValue, Iterable[ParamValue]], ) -> None: """ Merge the given parameters into this instance: values of keys that already exist are replaced (like ``dict.update``, at the position of the key's first pair), other keys are appended. Accepts the same forms as the constructor (query string, mapping, iterable of tuples, Params instance) plus keyword arguments. :param params: the parameters to merge in :param kwargs: parameters to merge in given as keyword arguments """ if params is not None: other = Params(params, self.separator) for key in other: self.set(key, other.get_all(key)) for key, value_s in kwargs.items(): self.set(key, value_s)
[docs] def clear(self) -> dict[str, list[str]]: # type: ignore[override] # ty: ignore[invalid-method-override] """ Remove all parameters, returns a dictionary of the cleared parameters (unlike ``MutableMapping.clear`` which returns ``None``). :return: a dictionary of removed keys and values """ old = self.as_dict() self._pairs = [] return old
[docs] def sort(self) -> None: """ Sort the pairs in place by their name and then by their values — the persistent equivalent of ``as_str(sort=True)``, which only sorts the rendered output. """ self._pairs.sort()
[docs] def as_str(self, sort: bool = False) -> str: """ A string representation of the parameters, url encoded. :param sort: sort the parameters by their name and then by their value, otherwise they'll be returned in the order of the representation :return: the url encoded query / file parameter string, e.g. "foo=bar&bar=baz&bar=abc" """ pairs = sorted(self._pairs) if sort else self._pairs param_str = urlencode(pairs) # urlencode always joins with "&"; a literal separator inside a value is # percent-encoded by then, so a plain replace cannot corrupt values if self.separator != "&": param_str = param_str.replace("&", self.separator) return param_str
[docs] def as_tuples(self) -> Iterator[tuple[str, list[str]]]: """ :return: the parameters grouped per key as an iterator of tuples with the values being lists of strings """ return iter(self.as_dict().items())
[docs] def as_single_tuples(self) -> Iterator[tuple[str, str]]: """ :return: the parameter representation as an iterator of tuples of the key and a single value, in representation order. This means keys with multiple values will appear more than once. """ return iter(list(self._pairs))
[docs] def as_dict(self) -> dict[str, list[str]]: """ :return: the parameters grouped per key as a dictionary with the parameter names as key and the values as lists of strings """ result: dict[str, list[str]] = {} for key, value in self._pairs: result.setdefault(key, []).append(value) return result
[docs] def singles(self) -> dict[str, str]: """ For those who are really sure that each parameter has only one value and do not want to bother with the lists for the values, this method will return only the last value for each key. WARNING: be aware, if the key has multiple values, only one of those will be returned for the key! :return: a dictionary with the parameters with a single value for each key """ return {key: values[-1] for key, values in self.as_dict().items()}
[docs] def uniq_tuples(self) -> Iterator[tuple[str, str]]: """ Same as :py:meth:`singles` but returning tuples. WARNING: be aware, if the key has multiple values, only one of those will be returned for the key! :return: an iterable of tuples with a key and a single value """ return iter(self.singles().items())
[docs] def __eq__(self, other: object) -> bool: """ Params are equal when they hold the same keys with the same values in the same per-key order; the order of the keys and the separator don't matter. Plain mappings are converted to Params before comparing. :param other: the Params instance or mapping to compare with :return: whether the parameters are equal """ if isinstance(other, Params): return self.as_dict() == other.as_dict() 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.as_dict() == Params(cast("ParamTypes", other)).as_dict() return NotImplemented
[docs] def __str__(self) -> str: return self.as_str()
[docs] def __repr__(self) -> str: return f"{self.__class__.__name__}({self.as_str()})"