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.


Discussion:
> I think we should consider updating annotations in webargs from Mapping to Dict.

Sure. This would be a fix since the code always expected a dict.

Resolving patch:
diff --git a/src/webargs/asyncparser.py b/src/webargs/asyncparser.py
--- a/src/webargs/asyncparser.py
+++ b/src/webargs/asyncparser.py
@@ -5,7 +5,6 @@
 import functools
 import inspect
 import typing
-from collections.abc import Mapping
 
 from marshmallow import Schema, ValidationError
 import marshmallow as ma
@@ -150,8 +149,8 @@ def use_args(
         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
--- a/src/webargs/core.py
+++ b/src/webargs/core.py
@@ -3,7 +3,6 @@
 import functools
 import typing
 import logging
-from collections.abc import Mapping
 import json
 
 import marshmallow as ma
@@ -26,7 +25,7 @@
 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 @@
 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 @@ def _get_schema(self, argmap: ArgMap, req: Request) -> ma.Schema:
             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 @@ def greet(args):
         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 @@ def greet(name):
         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 @@ def load_data(request, schema):
         :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 @@ def handle_error(error, req, schema, *, error_status_code, error_headers):
         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.


Test patch:
diff --git a/tests/test_core.py b/tests/test_core.py
--- 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 pre_load(self, location_data, *, schema, req, location):
     #   - 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)