Repository: pallets/werkzeug

Issue:
`CallbackDict` in datastructures.pyi is not utilizing a bound TypeVar
<!--
This issue tracker is a tool to address bugs in Werkzeug itself. Please
use Pallets Discord or Stack Overflow for questions about your own code.

Replace this comment with a clear outline of what the bug is.
-->

<!--
Describe how to replicate the bug.

Include a minimal reproducible example that demonstrates the bug.
Include the full traceback if there was an exception.
-->

<!--
Describe the expected behavior that should have happened but didn't.
-->

When creating a derived class of `CallbackDict`, I am unable to pass an `on_update` function annotated with the derived type and pass type checks with mypy or pyright.
The following is the derived class in question and associated `on_update` function (which has to be separate from the class to pass type checks due to a seeming quirk of `UpdateDictMixin` but that's a separate issue):

```python
from flask.sessions import SessionMixin
from typing import Union, Iterable, Tuple, Mapping, Optional, Any
from werkzeug.datastructures import CallbackDict


class MongoSession(CallbackDict, SessionMixin):

    def __init__(
        self,
        initial: Union[Mapping, Iterable[Tuple[Any, Any]], None] = None,
        sid: Optional[str] = None
    ):
        super(MongoSession, self).__init__(initial, on_update)
        self.sid = sid
        self.modified = False


def on_update(self: MongoSession) -> None:
    self.modified = True
```

Currently, mypy type checking will raise the following error here:
```
.../mongo_sessions.py:32: error: Argument 2 to "__init__" of "CallbackDict" has incompatible type "Callable[[MongoSession], None]"; expected "Optional[Callable[[CallbackDict[Any, Any]], None]]"
```

This is due to the specific use of `CallbackDict` in the datastructures.pyi file for the `on_update` parameter:
```python
class CallbackDict(UpdateDictMixin[K, V], Dict[K, V]):
    def __init__(
        self,
        initial: Optional[Union[Mapping[K, V], Iterable[Tuple[K, V]]]] = None,
        on_update: Optional[Callable[[CallbackDict], None]] = None,
    ) -> None: ...
```

This form of typing is interpreted as invariant, meaning that I *have* to pass in a function that specifically takes a `CallbackDict` and not a derived type.
In such a case I would not be able to modify properties on the derived class in `on_update` without incurring other typing errors (e.g. `modified` is not a property on `CallbackDict` nor `UpdateDictMixin`, but is in my derived class).

I would expect that I should be able to pass in an `on_update` function that is annotated to accept a derived class to perform more specific updates on.

This could be addressed by updating the `datastructure.pyi` file to use a `_CD = TypeVar("_CD", bound="CallbackDict")` instead of a concrete, invariant type annotation:
```python
_CD = TypeVar("_CD", bound="CallbackDict")

class CallbackDict(UpdateDictMixin[K, V], Dict[K, V]):
    def __init__(
        self,
        initial: Optional[Union[Mapping[K, V], Iterable[Tuple[K, V]]]] = None,
        on_update: Optional[Callable[[_CD], None]] = None,
    ) -> None: ...
```

I have tested this locally and it shows correctly rejecting a non-`CallbackDict` derived type annotation, but does accept derived types like the above `MongoSession`.

Environment:

- Python version: 3.6.8
- Werkzeug version: 2.0.1



Discussion:
Happy to review a PR.

Resolving patch:
diff --git a/src/werkzeug/sansio/response.py b/src/werkzeug/sansio/response.py
--- a/src/werkzeug/sansio/response.py
+++ b/src/werkzeug/sansio/response.py
@@ -316,7 +316,7 @@ def mimetype_params(self) -> t.Dict[str, str]:
         .. versionadded:: 0.5
         """
 
-        def on_update(d: t.Dict[str, str]) -> None:
+        def on_update(d: CallbackDict) -> None:
             self.headers["Content-Type"] = dump_options_header(self.mimetype, d)
 
         d = parse_options_header(self.headers.get("content-type", ""))[1]


Test patch:
diff --git a/src/werkzeug/test.py b/src/werkzeug/test.py
--- a/src/werkzeug/test.py
+++ b/src/werkzeug/test.py
@@ -570,7 +570,7 @@ def mimetype_params(self) -> t.Mapping[str, str]:
         .. versionadded:: 0.14
         """
 
-        def on_update(d: t.Mapping[str, str]) -> None:
+        def on_update(d: CallbackDict) -> None:
             self.headers["Content-Type"] = dump_options_header(self.mimetype, d)
 
         d = parse_options_header(self.headers.get("content-type", ""))[1]