Repository: pallets/werkzeug (python)

Issue:
Several issues with CacheControl types
This issue is in three parts:

### `no-transform` directive always returns None

This directive has no arguments (either in current RFC 9111 or older RFC 2616)

```python
from werkzeug.http import parse_cache_control_header

cc = parse_cache_control_header("no-transform")
print(f"{cc.no_transform=}")
```

Output: 
```
cc.no_transform=None
```

Expected: boolean based on presence

### `min-fresh` directive returns `"*"` if missing required argument

```python
from werkzeug.http import parse_cache_control_header

cc = parse_cache_control_header("min-fresh")
print(f"{cc.min_fresh=}")
```

Output:
```
cc.min_fresh='*'
```

Expected: `None` since the argument is required and `"*"` doesn't have a semantic meaning for this directive.

### type stubs are incorrect

- `no_cache` returns `str` not `bool`.
- `bool` properties never return `None`
- `max_stale` returns `"*"` in addition to `int`.
- `RequestCacheControl` immutability is not reflected.

Environment:

- Python version: N/A
- Werkzeug version: 3.0.2


Pull request description:
Fix CacheControl issues
- `no-transform` is a boolean, rather than always `None`.
- `min-fresh` is an int, returning `None` if empty rather than `*`.
- Fix some property types:
    - Bool properties never return `None`.
    - Non-bool types return `str` not `bool`.
    - `max-stale` can return `"*"`
    - Request properties are immutable
    - Fix some response setter types
- Add `must-understand` response property.

Fixes #2881

Patch:
diff --git a/CHANGES.rst b/CHANGES.rst
index fdc52692..0b788923 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -6,6 +6,10 @@ Version 3.1.0
 Unreleased
 
 -  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`
+
 
 Version 3.0.3
 -------------
diff --git a/src/werkzeug/datastructures/cache_control.py b/src/werkzeug/datastructures/cache_control.py
index bff4c18b..6ff4ecee 100644
--- a/src/werkzeug/datastructures/cache_control.py
+++ b/src/werkzeug/datastructures/cache_control.py
@@ -32,6 +32,10 @@ class _CacheControl(UpdateDictMixin, dict):
     to subclass it and add your own items have a look at the sourcecode for
     that class.
 
+    .. versionchanged:: 3.1
+
+       ``no_transform`` is a boolean when present.
+
     .. versionchanged:: 2.1.0
         Setting int properties such as ``max_age`` will convert the
         value to an int.
@@ -58,7 +62,7 @@ class _CacheControl(UpdateDictMixin, dict):
     no_cache = cache_control_property("no-cache", "*", None)
     no_store = cache_control_property("no-store", None, bool)
     max_age = cache_control_property("max-age", -1, int)
-    no_transform = cache_control_property("no-transform", None, None)
+    no_transform = cache_control_property("no-transform", None, bool)
 
     def __init__(self, values=(), on_update=None):
         dict.__init__(self, values or ())
@@ -127,6 +131,12 @@ class RequestCacheControl(ImmutableDictMixin, _CacheControl):
     you plan to subclass it and add your own items have a look at the sourcecode
     for that class.
 
+    .. versionchanged:: 3.1
+       ``no_transform`` is a boolean when present.
+
+    .. versionchanged:: 3.1
+       ``min_fresh`` is ``None`` if a value is not provided for the attribute.
+
     .. versionchanged:: 2.1.0
         Setting int properties such as ``max_age`` will convert the
         value to an int.
@@ -137,7 +147,7 @@ class RequestCacheControl(ImmutableDictMixin, _CacheControl):
     """
 
     max_stale = cache_control_property("max-stale", "*", int)
-    min_fresh = cache_control_property("min-fresh", "*", int)
+    min_fresh = cache_control_property("min-fresh", None, int)
     only_if_cached = cache_control_property("only-if-cached", None, bool)
 
 
@@ -151,6 +161,12 @@ class ResponseCacheControl(_CacheControl):
     you plan to subclass it and add your own items have a look at the sourcecode
     for that class.
 
+    .. versionchanged:: 3.1
+       ``no_transform`` is a boolean when present.
+
+    .. versionchanged:: 3.1
+        Added the ``must_understand`` attribute.
+
     .. versionchanged:: 2.1.1
         ``s_maxage`` converts the value to an int.
 
@@ -169,6 +185,7 @@ class ResponseCacheControl(_CacheControl):
     proxy_revalidate = cache_control_property("proxy-revalidate", None, bool)
     s_maxage = cache_control_property("s-maxage", None, int)
     immutable = cache_control_property("immutable", None, bool)
+    must_understand = cache_control_property("must-understand", None, bool)
 
 
 # circular dependencies
diff --git a/src/werkzeug/datastructures/cache_control.pyi b/src/werkzeug/datastructures/cache_control.pyi
index 54ec0208..4c9f4df3 100644
--- a/src/werkzeug/datastructures/cache_control.pyi
+++ b/src/werkzeug/datastructures/cache_control.pyi
@@ -1,6 +1,7 @@
 from collections.abc import Callable
 from collections.abc import Iterable
 from collections.abc import Mapping
+from typing import Literal
 from typing import TypeVar
 
 from .mixins import ImmutableDictMixin
@@ -24,13 +25,13 @@ class _CacheControl(
         on_update: Callable[[_CacheControl], None] | None = None,
     ) -> None: ...
     @property
-    def no_cache(self) -> bool | None: ...
+    def no_cache(self) -> str | None: ...
     @no_cache.setter
-    def no_cache(self, value: bool | None) -> None: ...
+    def no_cache(self, value: Literal[True] | str | None) -> None: ...
     @no_cache.deleter
     def no_cache(self) -> None: ...
     @property
-    def no_store(self) -> bool | None: ...
+    def no_store(self) -> bool: ...
     @no_store.setter
     def no_store(self, value: bool | None) -> None: ...
     @no_store.deleter
@@ -42,7 +43,7 @@ class _CacheControl(
     @max_age.deleter
     def max_age(self) -> None: ...
     @property
-    def no_transform(self) -> bool | None: ...
+    def no_transform(self) -> bool: ...
     @no_transform.setter
     def no_transform(self, value: bool | None) -> None: ...
     @no_transform.deleter
@@ -57,46 +58,42 @@ class _CacheControl(
 class RequestCacheControl(  # type: ignore[misc]
     ImmutableDictMixin[str, str | int | bool | None], _CacheControl
 ):
+    @property  # type: ignore
+    def no_cache(self) -> str | None: ...
+    @property  # type: ignore
+    def no_store(self) -> bool: ...
+    @property  # type: ignore
+    def max_age(self) -> int | None: ...
+    @property  # type: ignore
+    def no_transform(self) -> bool: ...
     @property
-    def max_stale(self) -> int | None: ...
-    @max_stale.setter
-    def max_stale(self, value: int | None) -> None: ...
-    @max_stale.deleter
-    def max_stale(self) -> None: ...
+    def max_stale(self) -> int | Literal["*"] | None: ...
     @property
     def min_fresh(self) -> int | None: ...
-    @min_fresh.setter
-    def min_fresh(self, value: int | None) -> None: ...
-    @min_fresh.deleter
-    def min_fresh(self) -> None: ...
     @property
     def only_if_cached(self) -> bool | None: ...
-    @only_if_cached.setter
-    def only_if_cached(self, value: bool | None) -> None: ...
-    @only_if_cached.deleter
-    def only_if_cached(self) -> None: ...
 
 class ResponseCacheControl(_CacheControl):
     @property
-    def public(self) -> bool | None: ...
+    def public(self) -> bool: ...
     @public.setter
     def public(self, value: bool | None) -> None: ...
     @public.deleter
     def public(self) -> None: ...
     @property
-    def private(self) -> bool | None: ...
+    def private(self) -> str | None: ...
     @private.setter
-    def private(self, value: bool | None) -> None: ...
+    def private(self, value: Literal[True] | str | None) -> None: ...
     @private.deleter
     def private(self) -> None: ...
     @property
-    def must_revalidate(self) -> bool | None: ...
+    def must_revalidate(self) -> bool: ...
     @must_revalidate.setter
     def must_revalidate(self, value: bool | None) -> None: ...
     @must_revalidate.deleter
     def must_revalidate(self) -> None: ...
     @property
-    def proxy_revalidate(self) -> bool | None: ...
+    def proxy_revalidate(self) -> bool: ...
     @proxy_revalidate.setter
     def proxy_revalidate(self, value: bool | None) -> None: ...
     @proxy_revalidate.deleter
@@ -108,8 +105,14 @@ class ResponseCacheControl(_CacheControl):
     @s_maxage.deleter
     def s_maxage(self) -> None: ...
     @property
-    def immutable(self) -> bool | None: ...
+    def immutable(self) -> bool: ...
     @immutable.setter
     def immutable(self, value: bool | None) -> None: ...
     @immutable.deleter
     def immutable(self) -> None: ...
+    @property
+    def must_understand(self) -> bool: ...
+    @must_understand.setter
+    def must_understand(self, value: bool | None) -> None: ...
+    @must_understand.deleter
+    def must_understand(self) -> None: ...
diff --git a/src/werkzeug/utils.py b/src/werkzeug/utils.py
index 59b97b73..b68ac18c 100644
--- a/src/werkzeug/utils.py
+++ b/src/werkzeug/utils.py
@@ -497,7 +497,7 @@ def send_file(
     elif mtime is not None:
         rv.last_modified = mtime  # type: ignore
 
-    rv.cache_control.no_cache = True
+    rv.cache_control.no_cache = True  # type: ignore[assignment]
 
     # Flask will pass app.get_send_file_max_age, allowing its send_file
     # wrapper to not have to deal with paths.


Test patch:
diff --git a/tests/test_datastructures.py b/tests/test_datastructures.py
index 64330e1e..830dfefd 100644
--- a/tests/test_datastructures.py
+++ b/tests/test_datastructures.py
@@ -953,6 +953,26 @@ class TestCacheControl:
         cc.no_cache = False
         assert cc.no_cache is False
 
+    def test_no_transform(self):
+        cc = ds.RequestCacheControl([("no-transform", None)])
+        assert cc.no_transform is True
+        cc = ds.RequestCacheControl()
+        assert cc.no_transform is False
+
+    def test_min_fresh(self):
+        cc = ds.RequestCacheControl([("min-fresh", "0")])
+        assert cc.min_fresh == 0
+        cc = ds.RequestCacheControl([("min-fresh", None)])
+        assert cc.min_fresh is None
+        cc = ds.RequestCacheControl()
+        assert cc.min_fresh is None
+
+    def test_must_understand(self):
+        cc = ds.ResponseCacheControl([("must-understand", None)])
+        assert cc.must_understand is True
+        cc = ds.ResponseCacheControl()
+        assert cc.must_understand is False
+
 
 class TestContentSecurityPolicy:
     def test_construct(self):