diff --git a/CHANGES.rst b/CHANGES.rst index 0a0616d57..de9c3a205 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,10 +1,50 @@ .. currentmodule:: werkzeug -Version 3.1.0 +Version 3.2.0 ------------- Unreleased + +Version 3.1.3 +------------- + +Released 2024-11-08 + +- Initial data passed to ``MultiDict`` and similar interfaces only accepts + ``list``, ``tuple``, or ``set`` when passing multiple values. It had been + changed to accept any ``Collection``, but this matched types that should be + treated as single values, such as ``bytes``. :issue:`2994` +- When the ``Host`` header is not set and ``Request.host`` falls back to the + WSGI ``SERVER_NAME`` value, if that value is an IPv6 address it is wrapped + in ``[]`` to match the ``Host`` header. :issue:`2993` + + +Version 3.1.2 +------------- + +Released 2024-11-04 + +- Improve type annotation for ``TypeConversionDict.get`` to allow the ``type`` + parameter to be a callable. :issue:`2988` +- ``Headers`` does not inherit from ``MutableMapping``, as it is does not + exactly match that interface. :issue:`2989` + + +Version 3.1.1 +------------- + +Released 2024-11-01 + +- Fix an issue that caused ``str(Request.headers)`` to always appear empty. + :issue:`2985` + + +Version 3.1.0 +------------- + +Released 2024-10-31 + - Drop support for Python 3.8. :pr:`2966` - Remove previously deprecated code. :pr:`2967` - ``Request.max_form_memory_size`` defaults to 500kB instead of unlimited. @@ -12,12 +52,31 @@ Unreleased error. :issue:`2964` - ``OrderedMultiDict`` and ``ImmutableOrderedMultiDict`` are deprecated. Use ``MultiDict`` and ``ImmutableMultiDict`` instead. :issue:`2968` +- Behavior of properties on ``request.cache_control`` and + ``response.cache_control`` has been significantly adjusted. + + - Dict values are always ``str | None``. Setting properties will convert + the value to a string. Setting a property to ``False`` is equivalent to + setting it to ``None``. Getting typed properties will return ``None`` if + conversion raises ``ValueError``, rather than the string. :issue:`2980` + - ``max_age`` is ``None`` if present without a value, rather than ``-1``. + :issue:`2980` + - ``no_cache`` is a boolean for requests, it is ``True`` instead of + ``"*"`` when present. It remains a string for responses. :issue:`2980` + - ``max_stale`` is ``True`` if present without a value, rather + than ``"*"``. :issue:`2980` + - ``no_transform`` is a boolean. Previously it was mistakenly always + ``None``. :issue:`2881` + - ``min_fresh`` is ``None`` if present without a value, rather than + ``"*"``. :issue:`2881` + - ``private`` is ``True`` if present without a value, rather than ``"*"``. + :issue:`2980` + - Added the ``must_understand`` property. :issue:`2881` + - Added the ``stale_while_revalidate``, and ``stale_if_error`` + properties. :issue:`2948` + - Type annotations more accurately reflect the values. :issue:`2881` + - Support Cookie CHIPS (Partitioned Cookies). :issue:`2797` -- ``CacheControl.no_transform`` is a boolean when present. ``min_fresh`` is - ``None`` when not present. Added the ``must_understand`` attribute. Fixed - some typing issues on cache control. :issue:`2881` -- Add ``stale_while_revalidate`` and ``stale_if_error`` properties to - ``ResponseCacheControl``. :issue:`2948` - Add 421 ``MisdirectedRequest`` HTTP exception. :issue:`2850` - Increase default work factor for PBKDF2 to 1,000,000 iterations. :issue:`2969` diff --git a/docs/datastructures.rst b/docs/datastructures.rst index bd2c0d223..e70252534 100644 --- a/docs/datastructures.rst +++ b/docs/datastructures.rst @@ -93,11 +93,13 @@ HTTP Related .. autoclass:: RequestCacheControl :members: - :inherited-members: + :inherited-members: ImmutableDictMixin, CallbackDict + :member-order: groupwise .. autoclass:: ResponseCacheControl :members: - :inherited-members: + :inherited-members: CallbackDict + :member-order: groupwise .. autoclass:: ETags :members: diff --git a/pyproject.toml b/pyproject.toml index 7eab71c89..bacd81d65 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "Werkzeug" -version = "3.1.0.dev" +version = "3.2.0.dev" description = "The comprehensive WSGI web application library." readme = "README.md" license = {file = "LICENSE.txt"} diff --git a/src/werkzeug/datastructures/cache_control.py b/src/werkzeug/datastructures/cache_control.py index e185944b3..8d700ab6a 100644 --- a/src/werkzeug/datastructures/cache_control.py +++ b/src/werkzeug/datastructures/cache_control.py @@ -2,27 +2,58 @@ import collections.abc as cabc import typing as t +from inspect import cleandoc from .mixins import ImmutableDictMixin from .structures import CallbackDict -def cache_control_property(key: str, empty: t.Any, type: type[t.Any] | None) -> t.Any: +def cache_control_property( + key: str, empty: t.Any, type: type[t.Any] | None, *, doc: str | None = None +) -> t.Any: """Return a new property object for a cache header. Useful if you want to add support for a cache extension in a subclass. + :param key: The attribute name present in the parsed cache-control header dict. + :param empty: The value to use if the key is present without a value. + :param type: The type to convert the string value to instead of a string. If + conversion raises a ``ValueError``, the returned value is ``None``. + :param doc: The docstring for the property. If not given, it is generated + based on the other params. + + .. versionchanged:: 3.1 + Added the ``doc`` param. + .. versionchanged:: 2.0 Renamed from ``cache_property``. """ + if doc is None: + parts = [f"The ``{key}`` attribute."] + + if type is bool: + parts.append("A ``bool``, either present or not.") + else: + if type is None: + parts.append("A ``str``,") + else: + parts.append(f"A ``{type.__name__}``,") + + if empty is not None: + parts.append(f"``{empty!r}`` if present with no value,") + + parts.append("or ``None`` if not present.") + + doc = " ".join(parts) + return property( lambda x: x._get_cache_value(key, empty, type), lambda x, v: x._set_cache_value(key, v, type), lambda x: x._del_cache_value(key), - f"accessor for {key!r}", + doc=cleandoc(doc), ) -class _CacheControl(CallbackDict[str, t.Any]): +class _CacheControl(CallbackDict[str, t.Optional[str]]): """Subclass of a dict that stores values for a Cache-Control header. It has accessors for all the cache-control directives specified in RFC 2616. The class does not differentiate between request and response directives. @@ -36,36 +67,25 @@ class _CacheControl(CallbackDict[str, t.Any]): that class. .. versionchanged:: 3.1 + Dict values are always ``str | None``. Setting properties will + convert the value to a string. Setting a non-bool property to + ``False`` is equivalent to setting it to ``None``. Getting typed + properties will return ``None`` if conversion raises + ``ValueError``, rather than the string. - ``no_transform`` is a boolean when present. - - .. versionchanged:: 2.1.0 + .. versionchanged:: 2.1 Setting int properties such as ``max_age`` will convert the value to an int. .. versionchanged:: 0.4 - - Setting `no_cache` or `private` to boolean `True` will set the implicit - none-value which is ``*``: - - >>> cc = ResponseCacheControl() - >>> cc.no_cache = True - >>> cc - - >>> cc.no_cache - '*' - >>> cc.no_cache = None - >>> cc - - - In versions before 0.5 the behavior documented here affected the now - no longer existing `CacheControl` class. + Setting ``no_cache`` or ``private`` to ``True`` will set the + implicit value ``"*"``. """ - no_cache: str | bool | None = cache_control_property("no-cache", "*", None) no_store: bool = cache_control_property("no-store", None, bool) - max_age: int | None = cache_control_property("max-age", -1, int) + max_age: int | None = cache_control_property("max-age", None, int) no_transform: bool = cache_control_property("no-transform", None, bool) + stale_if_error: int | None = cache_control_property("stale-if-error", None, int) def __init__( self, @@ -81,17 +101,20 @@ def _get_cache_value( """Used internally by the accessor properties.""" if type is bool: return key in self - if key in self: - value = self[key] - if value is None: - return empty - elif type is not None: - try: - value = type(value) - except ValueError: - pass - return value - return None + + if key not in self: + return None + + if (value := self[key]) is None: + return empty + + if type is not None: + try: + value = type(value) + except ValueError: + return None + + return value def _set_cache_value( self, key: str, value: t.Any, type: type[t.Any] | None @@ -102,16 +125,15 @@ def _set_cache_value( self[key] = None else: self.pop(key, None) + elif value is None or value is False: + self.pop(key, None) + elif value is True: + self[key] = None else: - if value is None: - self.pop(key, None) - elif value is True: - self[key] = None - else: - if type is not None: - self[key] = type(value) - else: - self[key] = value + if type is not None: + value = type(value) + + self[key] = str(value) def _del_cache_value(self, key: str) -> None: """Used internally by the accessor properties.""" @@ -132,7 +154,7 @@ def __repr__(self) -> str: cache_property = staticmethod(cache_control_property) -class RequestCacheControl(ImmutableDictMixin[str, t.Any], _CacheControl): # type: ignore[misc] +class RequestCacheControl(ImmutableDictMixin[str, t.Optional[str]], _CacheControl): # type: ignore[misc] """A cache control for requests. This is immutable and gives access to all the request-relevant cache control headers. @@ -142,21 +164,46 @@ class RequestCacheControl(ImmutableDictMixin[str, t.Any], _CacheControl): # typ for that class. .. versionchanged:: 3.1 - ``no_transform`` is a boolean when present. + Dict values are always ``str | None``. Setting properties will + convert the value to a string. Setting a non-bool property to + ``False`` is equivalent to setting it to ``None``. Getting typed + properties will return ``None`` if conversion raises + ``ValueError``, rather than the string. + + .. versionchanged:: 3.1 + ``max_age`` is ``None`` if present without a value, rather + than ``-1``. + + .. versionchanged:: 3.1 + ``no_cache`` is a boolean, it is ``True`` instead of ``"*"`` + when present. + + .. versionchanged:: 3.1 + ``max_stale`` is ``True`` if present without a value, rather + than ``"*"``. + + .. versionchanged:: 3.1 + ``no_transform`` is a boolean. Previously it was mistakenly + always ``None``. .. versionchanged:: 3.1 - ``min_fresh`` is ``None`` if a value is not provided for the attribute. + ``min_fresh`` is ``None`` if present without a value, rather + than ``"*"``. - .. versionchanged:: 2.1.0 + .. versionchanged:: 2.1 Setting int properties such as ``max_age`` will convert the value to an int. .. versionadded:: 0.5 - In previous versions a `CacheControl` class existed that was used - both for request and response. + Response-only properties are not present on this request class. """ - max_stale: str | int | None = cache_control_property("max-stale", "*", int) + no_cache: bool = cache_control_property("no-cache", None, bool) + max_stale: int | t.Literal[True] | None = cache_control_property( + "max-stale", + True, + int, + ) min_fresh: int | None = cache_control_property("min-fresh", None, int) only_if_cached: bool = cache_control_property("only-if-cached", None, bool) @@ -172,26 +219,46 @@ class ResponseCacheControl(_CacheControl): for that class. .. versionchanged:: 3.1 - ``no_transform`` is a boolean when present. + Dict values are always ``str | None``. Setting properties will + convert the value to a string. Setting a non-bool property to + ``False`` is equivalent to setting it to ``None``. Getting typed + properties will return ``None`` if conversion raises + ``ValueError``, rather than the string. + + .. versionchanged:: 3.1 + ``no_cache`` is ``True`` if present without a value, rather than + ``"*"``. + + .. versionchanged:: 3.1 + ``private`` is ``True`` if present without a value, rather than + ``"*"``. + + .. versionchanged:: 3.1 + ``no_transform`` is a boolean. Previously it was mistakenly + always ``None``. .. versionchanged:: 3.1 Added the ``must_understand``, ``stale_while_revalidate``, and - ``stale_if_error`` attributes. + ``stale_if_error`` properties. .. versionchanged:: 2.1.1 ``s_maxage`` converts the value to an int. - .. versionchanged:: 2.1.0 + .. versionchanged:: 2.1 Setting int properties such as ``max_age`` will convert the value to an int. .. versionadded:: 0.5 - In previous versions a `CacheControl` class existed that was used - both for request and response. + Request-only properties are not present on this response class. """ + no_cache: str | t.Literal[True] | None = cache_control_property( + "no-cache", True, None + ) public: bool = cache_control_property("public", None, bool) - private: str | None = cache_control_property("private", "*", None) + private: str | t.Literal[True] | None = cache_control_property( + "private", True, None + ) must_revalidate: bool = cache_control_property("must-revalidate", None, bool) proxy_revalidate: bool = cache_control_property("proxy-revalidate", None, bool) s_maxage: int | None = cache_control_property("s-maxage", None, int) @@ -200,7 +267,6 @@ class ResponseCacheControl(_CacheControl): stale_while_revalidate: int | None = cache_control_property( "stale-while-revalidate", None, int ) - stale_if_error: int | None = cache_control_property("stale-if-error", None, int) # circular dependencies diff --git a/src/werkzeug/datastructures/headers.py b/src/werkzeug/datastructures/headers.py index db53cda7b..1088e3bc9 100644 --- a/src/werkzeug/datastructures/headers.py +++ b/src/werkzeug/datastructures/headers.py @@ -17,7 +17,7 @@ T = t.TypeVar("T") -class Headers(cabc.MutableMapping[str, str]): +class Headers: """An object that stores some headers. It has a dict-like interface, but is ordered, can store the same key multiple times, and iterating yields ``(key, value)`` pairs instead of only keys. @@ -62,7 +62,7 @@ def __init__( defaults: ( Headers | MultiDict[str, t.Any] - | cabc.Mapping[str, t.Any | cabc.Collection[t.Any]] + | cabc.Mapping[str, t.Any | list[t.Any] | tuple[t.Any, ...] | set[t.Any]] | cabc.Iterable[tuple[str, t.Any]] | None ) = None, @@ -107,18 +107,21 @@ def lowered(item: tuple[str, ...]) -> tuple[str, ...]: __hash__ = None # type: ignore[assignment] - @t.overload # type: ignore[override] + @t.overload def get(self, key: str) -> str | None: ... @t.overload def get(self, key: str, default: str) -> str: ... @t.overload def get(self, key: str, default: T) -> str | T: ... @t.overload - def get(self, key: str, type: type[T]) -> T | None: ... + def get(self, key: str, type: cabc.Callable[[str], T]) -> T | None: ... @t.overload - def get(self, key: str, default: T, type: type[T]) -> T: ... + def get(self, key: str, default: T, type: cabc.Callable[[str], T]) -> T: ... def get( # type: ignore[misc] - self, key: str, default: str | T | None = None, type: type[T] | None = None + self, + key: str, + default: str | T | None = None, + type: cabc.Callable[[str], T] | None = None, ) -> str | T | None: """Return the default value if the requested data doesn't exist. If `type` is provided and is a callable it should convert the value, @@ -153,15 +156,17 @@ def get( # type: ignore[misc] return rv try: - return type(rv) # type: ignore[call-arg] + return type(rv) except ValueError: return default @t.overload def getlist(self, key: str) -> list[str]: ... @t.overload - def getlist(self, key: str, type: type[T]) -> list[T]: ... - def getlist(self, key: str, type: type[T] | None = None) -> list[str] | list[T]: + def getlist(self, key: str, type: cabc.Callable[[str], T]) -> list[T]: ... + def getlist( + self, key: str, type: cabc.Callable[[str], T] | None = None + ) -> list[str] | list[T]: """Return the list of items for a given key. If that key is not in the :class:`Headers`, the return value will be an empty list. Just like :meth:`get`, :meth:`getlist` accepts a `type` parameter. All items will @@ -187,7 +192,7 @@ def getlist(self, key: str, type: type[T] | None = None) -> list[str] | list[T]: for k, v in self: if k.lower() == ikey: try: - result.append(type(v)) # type: ignore[call-arg] + result.append(type(v)) except ValueError: continue @@ -203,17 +208,17 @@ def get_all(self, name: str) -> list[str]: """ return self.getlist(name) - def items(self, lower: bool = False) -> t.Iterable[tuple[str, str]]: # type: ignore[override] + def items(self, lower: bool = False) -> t.Iterable[tuple[str, str]]: for key, value in self: if lower: key = key.lower() yield key, value - def keys(self, lower: bool = False) -> t.Iterable[str]: # type: ignore[override] + def keys(self, lower: bool = False) -> t.Iterable[str]: for key, _ in self.items(lower): yield key - def values(self) -> t.Iterable[str]: # type: ignore[override] + def values(self) -> t.Iterable[str]: for _, value in self.items(): yield value @@ -222,7 +227,7 @@ def extend( arg: ( Headers | MultiDict[str, t.Any] - | cabc.Mapping[str, t.Any | cabc.Collection[t.Any]] + | cabc.Mapping[str, t.Any | list[t.Any] | tuple[t.Any, ...] | set[t.Any]] | cabc.Iterable[tuple[str, t.Any]] | None ) = None, @@ -317,7 +322,7 @@ def popitem(self) -> tuple[str, str]: """Removes a key or index and returns a (key, value) item.""" return self._list.pop() - def __contains__(self, key: str) -> bool: # type: ignore[override] + def __contains__(self, key: str) -> bool: """Check if a key is present.""" try: self._get_key(key) @@ -326,7 +331,7 @@ def __contains__(self, key: str) -> bool: # type: ignore[override] return True - def __iter__(self) -> t.Iterator[tuple[str, str]]: # type: ignore[override] + def __iter__(self) -> t.Iterator[tuple[str, str]]: """Yield ``(key, value)`` tuples.""" return iter(self._list) @@ -481,17 +486,19 @@ def __setitem__( else: self._list[key] = [(k, _str_header_value(v)) for k, v in value] # type: ignore[misc] - def update( # type: ignore[override] + def update( self, arg: ( Headers | MultiDict[str, t.Any] - | cabc.Mapping[str, t.Any | cabc.Collection[t.Any]] + | cabc.Mapping[ + str, t.Any | list[t.Any] | tuple[t.Any, ...] | cabc.Set[t.Any] + ] | cabc.Iterable[tuple[str, t.Any]] | None ) = None, /, - **kwargs: t.Any | cabc.Collection[t.Any], + **kwargs: t.Any | list[t.Any] | tuple[t.Any, ...] | cabc.Set[t.Any], ) -> None: """Replace headers in this object with items from another headers object and keyword arguments. @@ -511,9 +518,7 @@ def update( # type: ignore[override] self.setlist(key, arg.getlist(key)) elif isinstance(arg, cabc.Mapping): for key, value in arg.items(): - if isinstance(value, cabc.Collection) and not isinstance( - value, str - ): + if isinstance(value, (list, tuple, set)): self.setlist(key, value) else: self.set(key, value) @@ -522,13 +527,16 @@ def update( # type: ignore[override] self.set(key, value) for key, value in kwargs.items(): - if isinstance(value, cabc.Collection) and not isinstance(value, str): + if isinstance(value, (list, tuple, set)): self.setlist(key, value) else: self.set(key, value) def __or__( - self, other: cabc.Mapping[str, t.Any | cabc.Collection[t.Any]] + self, + other: cabc.Mapping[ + str, t.Any | list[t.Any] | tuple[t.Any, ...] | cabc.Set[t.Any] + ], ) -> te.Self: if not isinstance(other, cabc.Mapping): return NotImplemented @@ -540,13 +548,11 @@ def __or__( def __ior__( self, other: ( - cabc.Mapping[str, t.Any | cabc.Collection[t.Any]] + cabc.Mapping[str, t.Any | list[t.Any] | tuple[t.Any, ...] | cabc.Set[t.Any]] | cabc.Iterable[tuple[str, t.Any]] ), ) -> te.Self: - if not isinstance(other, (cabc.Mapping, cabc.Iterable)) or isinstance( - other, str - ): + if not isinstance(other, (cabc.Mapping, cabc.Iterable)): return NotImplemented self.update(other) @@ -557,7 +563,7 @@ def to_wsgi_list(self) -> list[tuple[str, str]]: :return: list """ - return list(self) # type: ignore[arg-type] + return list(self) def copy(self) -> te.Self: return self.__class__(self._list) @@ -568,13 +574,13 @@ def __copy__(self) -> te.Self: def __str__(self) -> str: """Returns formatted headers suitable for HTTP transmission.""" strs = [] - for key, value in self._list: + for key, value in self.to_wsgi_list(): strs.append(f"{key}: {value}") strs.append("\r\n") return "\r\n".join(strs) def __repr__(self) -> str: - return f"{type(self).__name__}({self._list!r})" + return f"{type(self).__name__}({list(self)!r})" def _options_header_vkw(value: str, kw: dict[str, t.Any]) -> str: @@ -635,7 +641,7 @@ def _get_key(self, key: str) -> str: def __len__(self) -> int: return sum(1 for _ in self) - def __iter__(self) -> cabc.Iterator[tuple[str, str]]: # type: ignore[override] + def __iter__(self) -> cabc.Iterator[tuple[str, str]]: for key, value in self.environ.items(): if key.startswith("HTTP_") and key not in { "HTTP_CONTENT_TYPE", diff --git a/src/werkzeug/datastructures/structures.py b/src/werkzeug/datastructures/structures.py index db2f99800..dbb7e8048 100644 --- a/src/werkzeug/datastructures/structures.py +++ b/src/werkzeug/datastructures/structures.py @@ -22,7 +22,7 @@ def iter_multi_items( mapping: ( MultiDict[K, V] - | cabc.Mapping[K, V | cabc.Collection[V]] + | cabc.Mapping[K, V | list[V] | tuple[V, ...] | set[V]] | cabc.Iterable[tuple[K, V]] ), ) -> cabc.Iterator[tuple[K, V]]: @@ -33,11 +33,11 @@ def iter_multi_items( yield from mapping.items(multi=True) elif isinstance(mapping, cabc.Mapping): for key, value in mapping.items(): - if isinstance(value, cabc.Collection) and not isinstance(value, str): + if isinstance(value, (list, tuple, set)): for v in value: yield key, v else: - yield key, value # type: ignore[misc] + yield key, value else: yield from mapping @@ -69,11 +69,14 @@ def get(self, key: K, default: V) -> V: ... @t.overload def get(self, key: K, default: T) -> V | T: ... @t.overload - def get(self, key: str, type: type[T]) -> T | None: ... + def get(self, key: str, type: cabc.Callable[[V], T]) -> T | None: ... @t.overload - def get(self, key: str, default: T, type: type[T]) -> T: ... + def get(self, key: str, default: T, type: cabc.Callable[[V], T]) -> T: ... def get( # type: ignore[misc] - self, key: K, default: V | T | None = None, type: type[T] | None = None + self, + key: K, + default: V | T | None = None, + type: cabc.Callable[[V], T] | None = None, ) -> V | T | None: """Return the default value if the requested data doesn't exist. If `type` is provided and is a callable it should convert the value, @@ -108,7 +111,7 @@ def get( # type: ignore[misc] return rv try: - return type(rv) # type: ignore[call-arg] + return type(rv) except (ValueError, TypeError): return default @@ -179,7 +182,7 @@ def __init__( self, mapping: ( MultiDict[K, V] - | cabc.Mapping[K, V | cabc.Collection[V]] + | cabc.Mapping[K, V | list[V] | tuple[V, ...] | set[V]] | cabc.Iterable[tuple[K, V]] | None ) = None, @@ -191,7 +194,7 @@ def __init__( elif isinstance(mapping, cabc.Mapping): tmp = {} for key, value in mapping.items(): - if isinstance(value, cabc.Collection) and not isinstance(value, str): + if isinstance(value, (list, tuple, set)): value = list(value) if not value: @@ -255,8 +258,10 @@ def add(self, key: K, value: V) -> None: @t.overload def getlist(self, key: K) -> list[V]: ... @t.overload - def getlist(self, key: K, type: type[T]) -> list[T]: ... - def getlist(self, key: K, type: type[T] | None = None) -> list[V] | list[T]: + def getlist(self, key: K, type: cabc.Callable[[V], T]) -> list[T]: ... + def getlist( + self, key: K, type: cabc.Callable[[V], T] | None = None + ) -> list[V] | list[T]: """Return the list of items for a given key. If that key is not in the `MultiDict`, the return value will be an empty list. Just like `get`, `getlist` accepts a `type` parameter. All items will be converted @@ -279,7 +284,7 @@ def getlist(self, key: K, type: type[T] | None = None) -> list[V] | list[T]: result = [] for item in rv: try: - result.append(type(item)) # type: ignore[call-arg] + result.append(type(item)) except (ValueError, TypeError): pass return result @@ -414,7 +419,7 @@ def update( # type: ignore[override] self, mapping: ( MultiDict[K, V] - | cabc.Mapping[K, V | cabc.Collection[V]] + | cabc.Mapping[K, V | list[V] | tuple[V, ...] | set[V]] | cabc.Iterable[tuple[K, V]] ), ) -> None: @@ -439,7 +444,7 @@ def update( # type: ignore[override] self.add(key, value) def __or__( # type: ignore[override] - self, other: cabc.Mapping[K, V | cabc.Collection[V]] + self, other: cabc.Mapping[K, V | list[V] | tuple[V, ...] | set[V]] ) -> MultiDict[K, V]: if not isinstance(other, cabc.Mapping): return NotImplemented @@ -450,11 +455,12 @@ def __or__( # type: ignore[override] def __ior__( # type: ignore[override] self, - other: cabc.Mapping[K, V | cabc.Collection[V]] | cabc.Iterable[tuple[K, V]], + other: ( + cabc.Mapping[K, V | list[V] | tuple[V, ...] | set[V]] + | cabc.Iterable[tuple[K, V]] + ), ) -> te.Self: - if not isinstance(other, (cabc.Mapping, cabc.Iterable)) or isinstance( - other, str - ): + if not isinstance(other, (cabc.Mapping, cabc.Iterable)): return NotImplemented self.update(other) @@ -595,7 +601,7 @@ def __init__( self, mapping: ( MultiDict[K, V] - | cabc.Mapping[K, V | cabc.Collection[V]] + | cabc.Mapping[K, V | list[V] | tuple[V, ...] | set[V]] | cabc.Iterable[tuple[K, V]] | None ) = None, @@ -707,8 +713,10 @@ def add(self, key: K, value: V) -> None: @t.overload def getlist(self, key: K) -> list[V]: ... @t.overload - def getlist(self, key: K, type: type[T]) -> list[T]: ... - def getlist(self, key: K, type: type[T] | None = None) -> list[V] | list[T]: + def getlist(self, key: K, type: cabc.Callable[[V], T]) -> list[T]: ... + def getlist( + self, key: K, type: cabc.Callable[[V], T] | None = None + ) -> list[V] | list[T]: rv: list[_omd_bucket[K, V]] try: @@ -720,7 +728,7 @@ def getlist(self, key: K, type: type[T] | None = None) -> list[V] | list[T]: result = [] for item in rv: try: - result.append(type(item.value)) # type: ignore[call-arg] + result.append(type(item.value)) except (ValueError, TypeError): pass return result @@ -737,7 +745,7 @@ def update( # type: ignore[override] self, mapping: ( MultiDict[K, V] - | cabc.Mapping[K, V | cabc.Collection[V]] + | cabc.Mapping[K, V | list[V] | tuple[V, ...] | set[V]] | cabc.Iterable[tuple[K, V]] ), ) -> None: @@ -852,17 +860,20 @@ def get(self, key: K, default: V) -> V: ... @t.overload def get(self, key: K, default: T) -> V | T: ... @t.overload - def get(self, key: str, type: type[T]) -> T | None: ... + def get(self, key: str, type: cabc.Callable[[V], T]) -> T | None: ... @t.overload - def get(self, key: str, default: T, type: type[T]) -> T: ... + def get(self, key: str, default: T, type: cabc.Callable[[V], T]) -> T: ... def get( # type: ignore[misc] - self, key: K, default: V | T | None = None, type: type[T] | None = None + self, + key: K, + default: V | T | None = None, + type: cabc.Callable[[V], T] | None = None, ) -> V | T | None: for d in self.dicts: if key in d: if type is not None: try: - return type(d[key]) # type: ignore[call-arg] + return type(d[key]) except (ValueError, TypeError): continue return d[key] @@ -871,8 +882,10 @@ def get( # type: ignore[misc] @t.overload def getlist(self, key: K) -> list[V]: ... @t.overload - def getlist(self, key: K, type: type[T]) -> list[T]: ... - def getlist(self, key: K, type: type[T] | None = None) -> list[V] | list[T]: + def getlist(self, key: K, type: cabc.Callable[[V], T]) -> list[T]: ... + def getlist( + self, key: K, type: cabc.Callable[[V], T] | None = None + ) -> list[V] | list[T]: rv = [] for d in self.dicts: rv.extend(d.getlist(key, type)) # type: ignore[arg-type] @@ -997,7 +1010,7 @@ def __init__( self, mapping: ( MultiDict[K, V] - | cabc.Mapping[K, V | cabc.Collection[V]] + | cabc.Mapping[K, V | list[V] | tuple[V, ...] | set[V]] | cabc.Iterable[tuple[K, V]] | None ) = None, diff --git a/src/werkzeug/exceptions.py b/src/werkzeug/exceptions.py index ddb621032..1cd999773 100644 --- a/src/werkzeug/exceptions.py +++ b/src/werkzeug/exceptions.py @@ -576,6 +576,8 @@ class MisdirectedRequest(HTTPException): Indicates that the request was directed to a server that is not able to produce a response. + + .. versionadded:: 3.1 """ code = 421 diff --git a/src/werkzeug/sansio/response.py b/src/werkzeug/sansio/response.py index 1c32b51de..9fed08625 100644 --- a/src/werkzeug/sansio/response.py +++ b/src/werkzeug/sansio/response.py @@ -223,6 +223,9 @@ def set_cookie( :param samesite: Limit the scope of the cookie to only be attached to requests that are "same-site". :param partitioned: If ``True``, the cookie will be partitioned. + + .. versionchanged:: 3.1 + The ``partitioned`` parameter was added. """ self.headers.add( "Set-Cookie", diff --git a/src/werkzeug/sansio/utils.py b/src/werkzeug/sansio/utils.py index 14fa0ac88..ff7ceda34 100644 --- a/src/werkzeug/sansio/utils.py +++ b/src/werkzeug/sansio/utils.py @@ -71,6 +71,9 @@ def get_host( :return: Host, with port if necessary. :raise ~werkzeug.exceptions.SecurityError: If the host is not trusted. + + .. versionchanged:: 3.1.3 + If ``SERVER_NAME`` is IPv6, it is wrapped in ``[]``. """ host = "" @@ -79,6 +82,11 @@ def get_host( elif server is not None: host = server[0] + # If SERVER_NAME is IPv6, wrap it in [] to match Host header. + # Check for : because domain or IPv4 can't have that. + if ":" in host and host[0] != "[": + host = f"[{host}]" + if server[1] is not None: host = f"{host}:{server[1]}" diff --git a/src/werkzeug/security.py b/src/werkzeug/security.py index a18381779..3f49ad1b4 100644 --- a/src/werkzeug/security.py +++ b/src/werkzeug/security.py @@ -92,6 +92,9 @@ def generate_password_hash( :param method: The key derivation function and parameters. :param salt_length: The number of characters to generate for the salt. + .. versionchanged:: 3.1 + The default iterations for pbkdf2 was increased to 1,000,000. + .. versionchanged:: 2.3 Scrypt support was added. diff --git a/src/werkzeug/utils.py b/src/werkzeug/utils.py index 59b97b732..3d3bbf066 100644 --- a/src/werkzeug/utils.py +++ b/src/werkzeug/utils.py @@ -150,7 +150,7 @@ def lookup(self, obj: Request) -> WSGIEnvironment: class header_property(_DictAccessorProperty[_TAccessorValue]): """Like `environ_property` but for headers.""" - def lookup(self, obj: Request | Response) -> Headers: + def lookup(self, obj: Request | Response) -> Headers: # type: ignore[override] return obj.headers diff --git a/tests/sansio/test_utils.py b/tests/sansio/test_utils.py index d43de66c2..a63e7c660 100644 --- a/tests/sansio/test_utils.py +++ b/tests/sansio/test_utils.py @@ -14,12 +14,16 @@ ("https", "spam", None, "spam"), ("https", "spam:443", None, "spam"), ("http", "spam:8080", None, "spam:8080"), + ("http", "127.0.0.1:8080", None, "127.0.0.1:8080"), + ("http", "[::1]:8080", None, "[::1]:8080"), ("ws", "spam", None, "spam"), ("ws", "spam:80", None, "spam"), ("wss", "spam", None, "spam"), ("wss", "spam:443", None, "spam"), ("http", None, ("spam", 80), "spam"), ("http", None, ("spam", 8080), "spam:8080"), + ("http", None, ("127.0.0.1", 8080), "127.0.0.1:8080"), + ("http", None, ("::1", 8080), "[::1]:8080"), ("http", None, ("unix/socket", None), "unix/socket"), ("http", "spam", ("eggs", 80), "spam"), ], diff --git a/tests/test_datastructures.py b/tests/test_datastructures.py index 76a5530fc..0cd497438 100644 --- a/tests/test_datastructures.py +++ b/tests/test_datastructures.py @@ -1,6 +1,9 @@ +from __future__ import annotations + import io import pickle import tempfile +import typing as t from contextlib import contextmanager from copy import copy from copy import deepcopy @@ -43,7 +46,7 @@ def items(self, multi=1): class _MutableMultiDictTests: - storage_class: type["ds.MultiDict"] + storage_class: type[ds.MultiDict] def test_pickle(self): cls = self.storage_class @@ -885,6 +888,10 @@ def test_ior(self) -> None: with pytest.raises(TypeError): headers |= {"y": "2"} + def test_str(self) -> None: + headers = ds.EnvironHeaders({"CONTENT_LENGTH": "50", "HTTP_HOST": "test"}) + assert str(headers) == "Content-Length: 50\r\nHost: test\r\n\r\n" + class TestHeaderSet: storage_class = ds.HeaderSet @@ -1000,7 +1007,7 @@ def test_set_none(self): cc.no_cache = None assert cc.no_cache is None cc.no_cache = False - assert cc.no_cache is False + assert cc.no_cache is None def test_no_transform(self): cc = ds.RequestCacheControl([("no-transform", None)]) @@ -1276,3 +1283,15 @@ def test_range_to_header(ranges): def test_range_validates_ranges(ranges): with pytest.raises(ValueError): ds.Range("bytes", ranges) + + +@pytest.mark.parametrize( + ("value", "expect"), + [ + ({"a": "ab"}, [("a", "ab")]), + ({"a": ["a", "b"]}, [("a", "a"), ("a", "b")]), + ({"a": b"ab"}, [("a", b"ab")]), + ], +) +def test_iter_multi_data(value: t.Any, expect: list[tuple[t.Any, t.Any]]) -> None: + assert list(ds.iter_multi_items(value)) == expect diff --git a/tests/test_http.py b/tests/test_http.py index 9febd0f0c..726b40bca 100644 --- a/tests/test_http.py +++ b/tests/test_http.py @@ -121,7 +121,7 @@ def test_dict_header(self, value, expect): def test_cache_control_header(self): cc = http.parse_cache_control_header("max-age=0, no-cache") assert cc.max_age == 0 - assert cc.no_cache + assert cc.no_cache is True cc = http.parse_cache_control_header( 'private, community="UCI"', None, datastructures.ResponseCacheControl ) @@ -132,17 +132,17 @@ def test_cache_control_header(self): assert c.no_cache is None assert c.private is None c.no_cache = True - assert c.no_cache == "*" + assert c.no_cache and c.no_cache is True c.private = True - assert c.private == "*" + assert c.private and c.private is True del c.private - assert c.private is None + assert not c.private and c.private is None # max_age is an int, other types are converted c.max_age = 3.1 - assert c.max_age == 3 + assert c.max_age == 3 and c["max-age"] == "3" del c.max_age c.s_maxage = 3.1 - assert c.s_maxage == 3 + assert c.s_maxage == 3 and c["s-maxage"] == "3" del c.s_maxage assert c.to_header() == "no-cache"