Repository: marshmallow-code/webargs

Issue:
The type of "argmap" allows for `Mapping[str, Field]`, but `Schema.from_dict` only supports `Dict[str, Field]`
I ran into this while looking at getting #663 merged.

We have arguments annotated as allowing a `Mapping`. The most likely usage for users is just a dict, and that is all that our examples show.
`Schema.from_dict` uses `dict.copy`. That's not part of the `Mapping` protocol in `collections.abc`.

I almost suggested that `Schema.from_dict` be modified to allow for a `Mapping` object. It's not particularly hard (switch `input_object.copy()` to `dict(input_object)`), but it seems wrong. There's no particular reason to support non-dict mappings for either `marshmallow` or `webargs`. I think our annotations in `webargs` are just inaccurate.

I think we should consider updating annotations in `webargs` from `Mapping` to `Dict`.

@lafrech, for now, I'm going to make an update to the #663 branch and add `dict(argmap)` calls to make the types align.

Comments:

Patch:
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 2af8116..e540cf3 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -20,9 +20,9 @@ repos:
     additional_dependencies: [black==21.12b0]
     args: ["--target-version", "py37"]
 - repo: https://github.com/pre-commit/mirrors-mypy
-  rev: v0.910-1
+  rev: v0.930
   hooks:
   - id: mypy
     language_version: python3
     files: ^src/webargs/
-    additional_dependencies: ["marshmallow>=3,<4"]
+    additional_dependencies: ["marshmallow>=3,<4", "packaging"]
diff --git a/setup.py b/setup.py
index a87ab5d..da04cfd 100644
--- a/setup.py
+++ b/setup.py
@@ -20,7 +20,7 @@ EXTRAS_REQUIRE = {
     ]
     + FRAMEWORKS,
     "lint": [
-        "mypy==0.910",
+        "mypy==0.930",
         "flake8==4.0.1",
         "flake8-bugbear==21.11.29",
         "pre-commit~=2.4",
diff --git a/src/webargs/__init__.py b/src/webargs/__init__.py
index e19b678..e7de3d8 100755
--- a/src/webargs/__init__.py
+++ b/src/webargs/__init__.py
@@ -1,3 +1,5 @@
+from __future__ import annotations
+
 from packaging.version import Version
 from marshmallow.utils import missing
 
@@ -9,7 +11,9 @@ from webargs import fields
 
 __version__ = "8.0.1"
 __parsed_version__ = Version(__version__)
-__version_info__ = __parsed_version__.release
+__version_info__: tuple[int, int, int] | tuple[
+    int, int, int, str, int
+] = __parsed_version__.release  # type: ignore[assignment]
 if __parsed_version__.pre:
-    __version_info__ += __parsed_version__.pre
+    __version_info__ += __parsed_version__.pre  # type: ignore[assignment]
 __all__ = ("ValidationError", "fields", "missing", "validate")
diff --git a/src/webargs/asyncparser.py b/src/webargs/asyncparser.py
index b0b1024..2335097 100644
--- a/src/webargs/asyncparser.py
+++ b/src/webargs/asyncparser.py
@@ -5,7 +5,6 @@ import asyncio
 import functools
 import inspect
 import typing
-from collections.abc import Mapping
 
 from marshmallow import Schema, ValidationError
 import marshmallow as ma
@@ -150,8 +149,8 @@ class AsyncParser(core.Parser):
         request_obj = req
         # Optimization: If argmap is passed as a dictionary, we only need
         # to generate a Schema once
-        if isinstance(argmap, Mapping):
-            argmap = self.schema_class.from_dict(dict(argmap))()
+        if isinstance(argmap, dict):
+            argmap = self.schema_class.from_dict(argmap)()
 
         def decorator(func: typing.Callable) -> typing.Callable:
             req_ = request_obj
diff --git a/src/webargs/core.py b/src/webargs/core.py
index b098cba..618a334 100644
--- a/src/webargs/core.py
+++ b/src/webargs/core.py
@@ -3,7 +3,6 @@ from __future__ import annotations
 import functools
 import typing
 import logging
-from collections.abc import Mapping
 import json
 
 import marshmallow as ma
@@ -26,7 +25,7 @@ __all__ = [
 Request = typing.TypeVar("Request")
 ArgMap = typing.Union[
     ma.Schema,
-    typing.Mapping[str, ma.fields.Field],
+    typing.Dict[str, typing.Union[ma.fields.Field, typing.Type[ma.fields.Field]]],
     typing.Callable[[Request], ma.Schema],
 ]
 ValidateArg = typing.Union[None, typing.Callable, typing.Iterable[typing.Callable]]
@@ -34,6 +33,11 @@ CallableList = typing.List[typing.Callable]
 ErrorHandler = typing.Callable[..., typing.NoReturn]
 # generic type var with no particular meaning
 T = typing.TypeVar("T")
+# type var for callables, to make type-preserving decorators
+C = typing.TypeVar("C", bound=typing.Callable)
+# type var for a callable which is an error handler
+# used to ensure that the error_handler decorator is type preserving
+ErrorHandlerT = typing.TypeVar("ErrorHandlerT", bound=ErrorHandler)
 
 
 # a value used as the default for arguments, so that when `None` is passed, it
@@ -257,8 +261,10 @@ class Parser:
             schema = argmap()
         elif callable(argmap):
             schema = argmap(req)
+        elif isinstance(argmap, dict):
+            schema = self.schema_class.from_dict(argmap)()
         else:
-            schema = self.schema_class.from_dict(dict(argmap))()
+            raise TypeError(f"argmap was of unexpected type {type(argmap)}")
         return schema
 
     def parse(
@@ -417,8 +423,8 @@ class Parser:
         request_obj = req
         # Optimization: If argmap is passed as a dictionary, we only need
         # to generate a Schema once
-        if isinstance(argmap, Mapping):
-            argmap = self.schema_class.from_dict(dict(argmap))()
+        if isinstance(argmap, dict):
+            argmap = self.schema_class.from_dict(argmap)()
 
         def decorator(func):
             req_ = request_obj
@@ -468,7 +474,7 @@ class Parser:
         kwargs["as_kwargs"] = True
         return self.use_args(*args, **kwargs)
 
-    def location_loader(self, name: str):
+    def location_loader(self, name: str) -> typing.Callable[[C], C]:
         """Decorator that registers a function for loading a request location.
         The wrapped function receives a schema and a request.
 
@@ -489,13 +495,13 @@ class Parser:
         :param str name: The name of the location to register.
         """
 
-        def decorator(func):
+        def decorator(func: C) -> C:
             self.__location_map__[name] = func
             return func
 
         return decorator
 
-    def error_handler(self, func: ErrorHandler) -> ErrorHandler:
+    def error_handler(self, func: ErrorHandlerT) -> ErrorHandlerT:
         """Decorator that registers a custom error handling function. The
         function should receive the raised error, request object,
         `marshmallow.Schema` instance used to parse the request, error status code,
@@ -523,8 +529,13 @@ class Parser:
         return func
 
     def pre_load(
-        self, location_data: Mapping, *, schema: ma.Schema, req: Request, location: str
-    ) -> Mapping:
+        self,
+        location_data: typing.Mapping,
+        *,
+        schema: ma.Schema,
+        req: Request,
+        location: str,
+    ) -> typing.Mapping:
         """A method of the parser which can transform data after location
         loading is done. By default it does nothing, but users can subclass
         parsers and override this method.
diff --git a/tox.ini b/tox.ini
index e021c50..81e3a8f 100644
--- a/tox.ini
+++ b/tox.ini
@@ -29,7 +29,7 @@ commands = pre-commit run --all-files
 # `webargs` and `marshmallow` both installed is a valuable safeguard against
 # issues in which `mypy` running on every file standalone won't catch things
 [testenv:mypy]
-deps = mypy==0.910
+deps = mypy==0.930
 extras = frameworks
 commands = mypy src/
 


Test patch:
diff --git a/tests/test_core.py b/tests/test_core.py
index 441f22d..bc8e97b 100644
--- a/tests/test_core.py
+++ b/tests/test_core.py
@@ -1,3 +1,4 @@
+import collections
 import datetime
 import typing
 from unittest import mock
@@ -1321,3 +1322,15 @@ def test_whitespace_stripping_parser_example(web_request):
     #   - ids=[" 1", ...]  will still parse okay because "  1" is valid for fields.Int
     ret = parser.parse(schema, web_request, location="json")
     assert ret == {"ids": [1, 3, 4], "values": [" foo  ", " bar"]}
+
+
+def test_parse_rejects_non_dict_argmap_mapping(parser, web_request):
+    web_request.json = {"username": 42, "password": 42}
+    argmap = collections.UserDict(
+        {"username": fields.Field(), "password": fields.Field()}
+    )
+
+    # UserDict is dict-like in all meaningful ways, but not a subclass of `dict`
+    # it will therefore be rejected with a TypeError when used
+    with pytest.raises(TypeError):
+        parser.parse(argmap, web_request)