Skip to content

Commit 15ca172

Browse files
committed
Add ability to pass unknown in parse calls
This adds support for passing the `unknown` parameter in two major locations: Parser instantiation, and Parser.parse calls. use_args and use_kwargs are just parse wrappers, and they need to pass it through as well. It also adds support for a class-level default for unknown, `Parser.DEFAULT_UNKNOWN`, which sets `unknown` for any future parser instances. Explicit tweaks to handle this were necessary in asyncparser and PyramidParser, due to odd method signatures. Support is tested in the core tests, but not the various framework tests. Add a 6.2.0 (Unreleased) changelog entry with detail on this change. The changelog states that we will change the DEFAULT_UNKNOWN default in a future major release. Presumably we'll make it `EXCLUDE`, but I'd like to make it location-dependent if feasible, so I didn't commit to anything in the phrasing.
1 parent 1bad8f9 commit 15ca172

File tree

5 files changed

+142
-18
lines changed

5 files changed

+142
-18
lines changed

CHANGELOG.rst

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,51 @@
11
Changelog
22
---------
33

4+
6.2.0 (Unreleased)
5+
******************
6+
7+
Features:
8+
9+
* Add a new ``unknown`` parameter to ``Parser.parse``, ``Parser.use_args``, and
10+
``Parser.use_kwargs``. When set, it will be passed to the ``Schema.load``
11+
call. If set to ``None`` (the default), no value is passed, so the schema's
12+
``unknown`` behavior is used.
13+
14+
This allows usages like
15+
16+
.. code-block:: python
17+
18+
import marshmallow as ma
19+
20+
# marshmallow 3 only, for use of ``unknown`` and ``EXCLUDE``
21+
@parser.use_kwargs(
22+
{"q1": ma.fields.Int(), "q2": ma.fields.Int()}, location="query", unknown=ma.EXCLUDE
23+
)
24+
def foo(q1, q2):
25+
...
26+
27+
* Add the ability to set defaults for ``unknown`` on either a Parser instance
28+
or Parser class. Set ``Parser.DEFAULT_UNKNOWN`` on a parser class to apply a value
29+
to any new parser instances created from that class, or set ``unknown`` during
30+
``Parser`` initialization.
31+
32+
Usages are varied, but include
33+
34+
.. code-block:: python
35+
36+
import marshmallow as ma
37+
from webargs.flaskparser import FlaskParser
38+
39+
parser = FlaskParser(unknown=ma.INCLUDE)
40+
41+
# as well as...
42+
class MyParser(FlaskParser):
43+
DEFAULT_UNKNOWN = ma.INCLUDE
44+
45+
46+
parser = MyParser()
47+
48+
449
6.1.0 (2020-04-05)
550
******************
651

src/webargs/asyncparser.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from marshmallow.fields import Field
1010
import marshmallow as ma
1111

12+
from webargs.compat import MARSHMALLOW_VERSION_INFO
1213
from webargs import core
1314

1415
Request = typing.TypeVar("Request")
@@ -28,6 +29,7 @@ async def parse(
2829
req: Request = None,
2930
*,
3031
location: str = None,
32+
unknown: str = None,
3133
validate: Validate = None,
3234
error_status_code: typing.Union[int, None] = None,
3335
error_headers: typing.Union[typing.Mapping[str, str], None] = None
@@ -38,6 +40,10 @@ async def parse(
3840
"""
3941
req = req if req is not None else self.get_default_request()
4042
location = location or self.location
43+
unknown = unknown or self.unknown
44+
load_kwargs = (
45+
{"unknown": unknown} if MARSHMALLOW_VERSION_INFO[0] >= 3 and unknown else {}
46+
)
4147
if req is None:
4248
raise ValueError("Must pass req object")
4349
data = None
@@ -47,7 +53,7 @@ async def parse(
4753
location_data = await self._load_location_data(
4854
schema=schema, req=req, location=location
4955
)
50-
result = schema.load(location_data)
56+
result = schema.load(location_data, **load_kwargs)
5157
data = result.data if core.MARSHMALLOW_VERSION_INFO[0] < 3 else result
5258
self._validate_arguments(data, validators)
5359
except ma.exceptions.ValidationError as error:
@@ -111,6 +117,7 @@ def use_args(
111117
req: typing.Optional[Request] = None,
112118
*,
113119
location: str = None,
120+
unknown=None,
114121
as_kwargs: bool = False,
115122
validate: Validate = None,
116123
error_status_code: typing.Optional[int] = None,
@@ -143,6 +150,7 @@ async def wrapper(*args, **kwargs):
143150
argmap,
144151
req=req_obj,
145152
location=location,
153+
unknown=unknown,
146154
validate=validate,
147155
error_status_code=error_status_code,
148156
error_headers=error_headers,
@@ -165,6 +173,7 @@ def wrapper(*args, **kwargs):
165173
argmap,
166174
req=req_obj,
167175
location=location,
176+
unknown=unknown,
168177
validate=validate,
169178
error_status_code=error_status_code,
170179
error_headers=error_headers,

src/webargs/core.py

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -101,11 +101,15 @@ class Parser:
101101
etc.
102102
103103
:param str location: Default location to use for data
104+
:param str unknown: Default value for ``unknown`` in ``parse``,
105+
``use_args``, and ``use_kwargs``
104106
:param callable error_handler: Custom error handler function.
105107
"""
106108

107109
#: Default location to check for data
108110
DEFAULT_LOCATION = "json"
111+
#: Default value to use for 'unknown' on schema load
112+
DEFAULT_UNKNOWN = None
109113
#: The marshmallow Schema class to use when creating new schemas
110114
DEFAULT_SCHEMA_CLASS = ma.Schema
111115
#: Default status code to return for validation errors
@@ -125,10 +129,13 @@ class Parser:
125129
"json_or_form": "load_json_or_form",
126130
}
127131

128-
def __init__(self, location=None, *, error_handler=None, schema_class=None):
132+
def __init__(
133+
self, location=None, *, unknown=None, error_handler=None, schema_class=None
134+
):
129135
self.location = location or self.DEFAULT_LOCATION
130136
self.error_callback = _callable_or_raise(error_handler)
131137
self.schema_class = schema_class or self.DEFAULT_SCHEMA_CLASS
138+
self.unknown = unknown or self.DEFAULT_UNKNOWN
132139

133140
def _get_loader(self, location):
134141
"""Get the loader function for the given location.
@@ -222,6 +229,7 @@ def parse(
222229
req=None,
223230
*,
224231
location=None,
232+
unknown=None,
225233
validate=None,
226234
error_status_code=None,
227235
error_headers=None
@@ -236,6 +244,8 @@ def parse(
236244
Can be any of the values in :py:attr:`~__location_map__`. By
237245
default, that means one of ``('json', 'query', 'querystring',
238246
'form', 'headers', 'cookies', 'files', 'json_or_form')``.
247+
:param str unknown: A value to pass for ``unknown`` when calling the
248+
schema's ``load`` method (marshmallow 3 only).
239249
:param callable validate: Validation function or list of validation functions
240250
that receives the dictionary of parsed arguments. Validator either returns a
241251
boolean or raises a :exc:`ValidationError`.
@@ -248,6 +258,10 @@ def parse(
248258
"""
249259
req = req if req is not None else self.get_default_request()
250260
location = location or self.location
261+
unknown = unknown or self.unknown
262+
load_kwargs = (
263+
{"unknown": unknown} if MARSHMALLOW_VERSION_INFO[0] >= 3 and unknown else {}
264+
)
251265
if req is None:
252266
raise ValueError("Must pass req object")
253267
data = None
@@ -257,7 +271,7 @@ def parse(
257271
location_data = self._load_location_data(
258272
schema=schema, req=req, location=location
259273
)
260-
result = schema.load(location_data)
274+
result = schema.load(location_data, **load_kwargs)
261275
data = result.data if MARSHMALLOW_VERSION_INFO[0] < 3 else result
262276
self._validate_arguments(data, validators)
263277
except ma.exceptions.ValidationError as error:
@@ -307,6 +321,7 @@ def use_args(
307321
req=None,
308322
*,
309323
location=None,
324+
unknown=None,
310325
as_kwargs=False,
311326
validate=None,
312327
error_status_code=None,
@@ -325,6 +340,8 @@ def greet(args):
325340
of argname -> `marshmallow.fields.Field` pairs, or a callable
326341
which accepts a request and returns a `marshmallow.Schema`.
327342
:param str location: Where on the request to load values.
343+
:param str unknown: A value to pass for ``unknown`` when calling the
344+
schema's ``load`` method (marshmallow 3 only).
328345
:param bool as_kwargs: Whether to insert arguments as keyword arguments.
329346
:param callable validate: Validation function that receives the dictionary
330347
of parsed arguments. If the function returns ``False``, the parser
@@ -356,6 +373,7 @@ def wrapper(*args, **kwargs):
356373
argmap,
357374
req=req_obj,
358375
location=location,
376+
unknown=unknown,
359377
validate=validate,
360378
error_status_code=error_status_code,
361379
error_headers=error_headers,

src/webargs/pyramidparser.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,7 @@ def use_args(
113113
req=None,
114114
*,
115115
location=core.Parser.DEFAULT_LOCATION,
116+
unknown=None,
116117
as_kwargs=False,
117118
validate=None,
118119
error_status_code=None,
@@ -127,6 +128,8 @@ def use_args(
127128
which accepts a request and returns a `marshmallow.Schema`.
128129
:param req: The request object to parse. Pulled off of the view by default.
129130
:param str location: Where on the request to load values.
131+
:param str unknown: A value to pass for ``unknown`` when calling the
132+
schema's ``load`` method (marshmallow 3 only).
130133
:param bool as_kwargs: Whether to insert arguments as keyword arguments.
131134
:param callable validate: Validation function that receives the dictionary
132135
of parsed arguments. If the function returns ``False``, the parser
@@ -155,6 +158,7 @@ def wrapper(obj, *args, **kwargs):
155158
argmap,
156159
req=request,
157160
location=location,
161+
unknown=unknown,
158162
validate=validate,
159163
error_status_code=error_status_code,
160164
error_headers=error_headers,

tests/test_core.py

Lines changed: 63 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,11 @@ def test_parse(parser, web_request):
108108
@pytest.mark.skipif(
109109
MARSHMALLOW_VERSION_INFO[0] < 3, reason="unknown=... added in marshmallow3"
110110
)
111-
def test_parse_with_unknown_behavior_specified(parser, web_request):
111+
@pytest.mark.parametrize(
112+
"set_location",
113+
["schema_instance", "parse_call", "parser_default", "parser_class_default"],
114+
)
115+
def test_parse_with_unknown_behavior_specified(parser, web_request, set_location):
112116
# This is new in webargs 6.x ; it's the way you can "get back" the behavior
113117
# of webargs 5.x in which extra args are ignored
114118
from marshmallow import EXCLUDE, INCLUDE, RAISE
@@ -119,17 +123,65 @@ class CustomSchema(Schema):
119123
username = fields.Field()
120124
password = fields.Field()
121125

126+
def parse_with_desired_behavior(value):
127+
if set_location == "schema_instance":
128+
if value is not None:
129+
return parser.parse(CustomSchema(unknown=value), web_request)
130+
else:
131+
return parser.parse(CustomSchema(), web_request)
132+
elif set_location == "parse_call":
133+
return parser.parse(CustomSchema(), web_request, unknown=value)
134+
elif set_location == "parser_default":
135+
parser.unknown = value
136+
return parser.parse(CustomSchema(), web_request)
137+
elif set_location == "parser_class_default":
138+
139+
class CustomParser(MockRequestParser):
140+
DEFAULT_UNKNOWN = value
141+
142+
return CustomParser().parse(CustomSchema(), web_request)
143+
else:
144+
raise NotImplementedError
145+
122146
# with no unknown setting or unknown=RAISE, it blows up
123147
with pytest.raises(ValidationError, match="Unknown field."):
124-
parser.parse(CustomSchema(), web_request)
148+
parse_with_desired_behavior(None)
125149
with pytest.raises(ValidationError, match="Unknown field."):
126-
parser.parse(CustomSchema(unknown=RAISE), web_request)
150+
parse_with_desired_behavior(RAISE)
127151

128152
# with unknown=EXCLUDE the data is omitted
129-
ret = parser.parse(CustomSchema(unknown=EXCLUDE), web_request)
153+
ret = parse_with_desired_behavior(EXCLUDE)
130154
assert {"username": 42, "password": 42} == ret
131155
# with unknown=INCLUDE it is added even though it isn't part of the schema
132-
ret = parser.parse(CustomSchema(unknown=INCLUDE), web_request)
156+
ret = parse_with_desired_behavior(INCLUDE)
157+
assert {"username": 42, "password": 42, "fjords": 42} == ret
158+
159+
160+
@pytest.mark.skipif(
161+
MARSHMALLOW_VERSION_INFO[0] < 3, reason="unknown=... added in marshmallow3"
162+
)
163+
def test_parse_with_explicit_unknown_overrides_schema(parser, web_request):
164+
# this test ensures that if you specify unknown=... in your parse call (or
165+
# use_args) it takes precedence over a setting in the schema object
166+
from marshmallow import EXCLUDE, INCLUDE, RAISE
167+
168+
web_request.json = {"username": 42, "password": 42, "fjords": 42}
169+
170+
class CustomSchema(Schema):
171+
username = fields.Field()
172+
password = fields.Field()
173+
174+
# setting RAISE in the parse call overrides schema setting
175+
with pytest.raises(ValidationError, match="Unknown field."):
176+
parser.parse(CustomSchema(unknown=EXCLUDE), web_request, unknown=RAISE)
177+
with pytest.raises(ValidationError, match="Unknown field."):
178+
parser.parse(CustomSchema(unknown=INCLUDE), web_request, unknown=RAISE)
179+
180+
# and the reverse -- setting EXCLUDE or INCLUDE in the parse call overrides
181+
# a schema with RAISE already set
182+
ret = parser.parse(CustomSchema(unknown=RAISE), web_request, unknown=EXCLUDE)
183+
assert {"username": 42, "password": 42} == ret
184+
ret = parser.parse(CustomSchema(unknown=RAISE), web_request, unknown=INCLUDE)
133185
assert {"username": 42, "password": 42, "fjords": 42} == ret
134186

135187

@@ -756,22 +808,18 @@ def test_warning_raised_if_schema_is_not_in_strict_mode(self, web_request, parse
756808
assert "strict=True" in str(warning.message)
757809

758810
def test_use_kwargs_stacked(self, web_request, parser):
811+
parse_kwargs = {}
759812
if MARSHMALLOW_VERSION_INFO[0] >= 3:
760813
from marshmallow import EXCLUDE
761814

762-
class PageSchema(Schema):
763-
page = fields.Int()
764-
765-
pageschema = PageSchema(unknown=EXCLUDE)
766-
userschema = self.UserSchema(unknown=EXCLUDE)
767-
else:
768-
pageschema = {"page": fields.Int()}
769-
userschema = self.UserSchema(**strict_kwargs)
815+
parse_kwargs = {"unknown": EXCLUDE}
770816

771817
web_request.json = {"email": "foo@bar.com", "password": "bar", "page": 42}
772818

773-
@parser.use_kwargs(pageschema, web_request)
774-
@parser.use_kwargs(userschema, web_request)
819+
@parser.use_kwargs({"page": fields.Int()}, web_request, **parse_kwargs)
820+
@parser.use_kwargs(
821+
self.UserSchema(**strict_kwargs), web_request, **parse_kwargs
822+
)
775823
def viewfunc(email, password, page):
776824
return {"email": email, "password": password, "page": page}
777825

0 commit comments

Comments
 (0)