Skip to content

Commit 8278374

Browse files
committed
Refactor support for 'unknown' with location map
This introduces a new Parser-class variable, `DEFAULT_UNKNOWN_BY_LOCATION`, which maps location names to `unknown` values. It populates that and `DEFAULT_UNKNOWN` to pass `EXCLUDE` in the general case and `RAISE` for request bodies. The new behavior is layered and defined with lower precedence than `Parser.unknown` or the `unknown` parameter on parse calls. In order to implement this and allow users to opt *out* of the behavior (i.e. in order to use schema-defined values), the way these values are set is subtly changed. Instead of having a default value of `None`, parse calls and parser __init__ have a default of `unknown="_default"`. When that value is detected, the various layers of defaults (DEFAULT_UNKNOWN and DEFAULT_UNKNOWN_BY_LOCATION) are applied. However, if a user passes `None`, that will take effect with the meaning "do not pass a value for `unknown`". For parsers which define additional locations, they extend the base DEFAULT_UNKNOWN_BY_LOCATION as appropriate. The changelog has been updated significantly in order to handle this and a new section of the advanced usage docs covers the behavior.
1 parent 54affe8 commit 8278374

14 files changed

Lines changed: 281 additions & 51 deletions

File tree

CHANGELOG.rst

Lines changed: 24 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,11 @@ Refactoring:
2727
2828
Features:
2929

30-
* Add a new ``unknown`` parameter to ``Parser.parse``, ``Parser.use_args``, and
31-
``Parser.use_kwargs``. When set, it will be passed to the ``Schema.load``
32-
call. If set to ``None`` (the default), no value is passed, so the schema's
33-
``unknown`` behavior is used.
30+
* Add ``unknown`` as a parameter to ``Parser.parse``, ``Parser.use_args``, and
31+
``Parser.use_kwargs``, or to parser instantiation. When set, it will be passed
32+
to ``Schema.load``. When not set, the value passed will depend on the parser's
33+
settings. If set to ``None``, the schema's default behavior will be used (i.e.
34+
no value is passed to ``Schema.load``) and parser settings will be ignored.
3435

3536
This allows usages like
3637

@@ -45,10 +46,10 @@ This allows usages like
4546
def foo(q1, q2):
4647
...
4748
48-
* Add the ability to set defaults for ``unknown`` on either a Parser instance
49-
or Parser class. Set ``Parser.DEFAULT_UNKNOWN`` on a parser class to apply a value
50-
to any new parser instances created from that class, or set ``unknown`` during
51-
``Parser`` initialization.
49+
* Defaults for ``unknown`` may be customized on parser classes via
50+
``Parser.DEFAULT_UNKNOWN_BY_LOCATION``, which maps location names to values
51+
to use, and ``Parser.DEFAULT_UNKNOWN``, which is used when a location is not
52+
found in ``DEFAULT_UNKNOWN_BY_LOCATION``.
5253

5354
Usages are varied, but include
5455

@@ -61,11 +62,25 @@ Usages are varied, but include
6162
6263
# as well as...
6364
class MyParser(FlaskParser):
64-
DEFAULT_UNKNOWN = ma.INCLUDE
65+
DEFAULT_UNKNOWN_BY_LOCATION = {"query": ma.INCLUDE}
6566
6667
6768
parser = MyParser()
6869
70+
Setting the ``unknown`` value for a Parser instance has higher precedence. So
71+
72+
.. code-block:: python
73+
74+
parser = MyParser(unknown=ma.RAISE)
75+
76+
will always pass ``RAISE``, even when the location is ``query``.
77+
78+
* By default, webargs will pass ``unknown=EXCLUDE`` for all locations except
79+
for request bodies (``json``, ``form``, and ``json_or_form``) and path
80+
parameters. Request bodies and path parameters will pass ``unknown=RAISE``.
81+
This behavior is defined by the default values for ``DEFAULT_UNKNOWN`` and
82+
``DEFAULT_UNKNOWN_BY_LOCATION``.
83+
6984
Changes:
7085

7186
* Registered `error_handler` callbacks are required to raise an exception.

docs/advanced.rst

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,113 @@ When you need more flexibility in defining input schemas, you can pass a marshma
128128
# ...
129129
130130
131+
Setting `unknown`
132+
-----------------
133+
134+
webargs supports several ways of setting and passing the `unknown` parameter
135+
for `handling unknown fields <https://marshmallow.readthedocs.io/en/stable/quickstart.html#handling-unknown-fields>`_.
136+
137+
You can pass `unknown=...` as a parameter to any of
138+
`Parser.parse <webargs.core.Parser.parse>`,
139+
`Parser.use_args <webargs.core.Parser.use_args>`, and
140+
`Parser.use_kwargs <webargs.core.Parser.use_kwargs>`.
141+
142+
143+
.. note::
144+
145+
The `unknown` value is passed to the schema's `load()` call. It therefore
146+
only applies to the top layer when nesting is used. To control `unknown` at
147+
multiple layers of a nested schema, you must use other mechanisms, like
148+
the `unknown` argument to `fields.Nested`.
149+
150+
Default `unknown`
151+
+++++++++++++++++
152+
153+
By default, webargs will pass `unknown=marshmallow.EXCLUDE` except when the
154+
location is `json`, `form`, or `json_or_form`. In those cases, it uses
155+
`unknown=marshmallow.RAISE` instead.
156+
157+
You can change these defaults by overriding `DEFAULT_UNKNOWN_BY_LOCATION` and
158+
`DEFAULT_UNKNOWN`. The first is a mapping of locations to values to pass, and
159+
the second is the fallback value used if the location is not included in the
160+
map.
161+
162+
You can also define a default at parser instantiation, which will take
163+
precedence over these defaults.
164+
165+
For example,
166+
167+
.. code-block:: python
168+
169+
from flask import Flask
170+
from marshmallow import EXCLUDE, INCLUDE, fields
171+
from webargs.flaskparser import FlaskParser
172+
173+
app = Flask(__name__)
174+
175+
176+
class Parser(FlaskParser):
177+
DEFAULT_UNKNOWN = INCLUDE
178+
DEFAULT_UNKNOWN_BY_LOCATION = {"query": EXCLUDE}
179+
180+
181+
parser = Parser()
182+
183+
184+
# location is "query", which is listed in DEFAULT_UNKNOWN_BY_LOCATION,
185+
# so EXCLUDE will be used
186+
@app.route("/", methods=["GET"])
187+
@parser.use_args({"foo": fields.Int()}, location="query")
188+
def get(self, args):
189+
return f"foo x 2 = {args['foo'] * 2}"
190+
191+
192+
# location is "json", which is not in DEFAULT_UNKNOWN_BY_LOCATION,
193+
# so the parser's default value, `INCLUDE`, will be used
194+
@app.route("/", methods=["POST"])
195+
@parser.use_args({"foo": fields.Int(), "bar": fields.Int()}, location="json")
196+
def post(self, args):
197+
unexpected_args = [k for k in args.keys() if k not in ("foo", "bar")]
198+
return f"foo x bar = {args['foo'] * args['bar']}; unexpected args={unexpected_args}"
199+
200+
201+
Using Schema-Specfied `unknown`
202+
+++++++++++++++++++++++++++++++
203+
204+
If you wish to use the value of `unknown` specified by a schema, simply pass
205+
``unknown=None``. This will disable webargs' automatic passing of values for
206+
``unknown``. For example,
207+
208+
.. code-block:: python
209+
210+
from flask import Flask
211+
from marshmallow import Schema, fields, EXCLUDE, missing
212+
from webargs.flaskparser import use_args
213+
214+
215+
class RectangleSchema(Schema):
216+
length = fields.Float()
217+
width = fields.Float()
218+
219+
class Meta:
220+
unknown = EXCLUDE
221+
222+
223+
app = Flask(__name__)
224+
225+
# because unknown=None was passed, no value is passed during schema loading
226+
# as a result, the schema's behavior (EXCLUDE) is used
227+
@app.route("/", methods=["POST"])
228+
@use_args(RectangleSchema(), location="json", unknown=None)
229+
def get(self, args):
230+
return f"area = {args['length'] * args['width']}"
231+
232+
233+
You can also set ``unknown=None`` when instantiating a parser, or set
234+
``DEFAULT_UNKNOWN = None`` to make this behavior the default for a parser
235+
class.
236+
237+
131238
When to avoid `use_kwargs`
132239
--------------------------
133240

src/webargs/aiohttpparser.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ def index(request, args):
2727
from aiohttp import web
2828
from aiohttp.web import Request
2929
from aiohttp import web_exceptions
30-
from marshmallow import Schema, ValidationError
30+
from marshmallow import Schema, ValidationError, RAISE
3131

3232
from webargs import core
3333
from webargs.core import json
@@ -72,6 +72,11 @@ def _find_exceptions() -> None:
7272
class AIOHTTPParser(AsyncParser):
7373
"""aiohttp request argument parser."""
7474

75+
DEFAULT_UNKNOWN_BY_LOCATION = {
76+
"match_info": RAISE,
77+
"path": RAISE,
78+
**core.Parser.DEFAULT_UNKNOWN_BY_LOCATION,
79+
}
7580
__location_map__ = dict(
7681
match_info="load_match_info",
7782
path="load_match_info",

src/webargs/asyncparser.py

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ async def parse(
2828
req: Request = None,
2929
*,
3030
location: str = None,
31-
unknown: str = None,
31+
unknown: str = core._UNKNOWN_DEFAULT_PARAM,
3232
validate: Validate = None,
3333
error_status_code: typing.Union[int, None] = None,
3434
error_headers: typing.Union[typing.Mapping[str, str], None] = None
@@ -39,7 +39,17 @@ async def parse(
3939
"""
4040
req = req if req is not None else self.get_default_request()
4141
location = location or self.location
42-
unknown = unknown or self.unknown
42+
unknown = (
43+
unknown
44+
if unknown != core._UNKNOWN_DEFAULT_PARAM
45+
else (
46+
self.unknown
47+
if self.unknown != core._UNKNOWN_DEFAULT_PARAM
48+
else self.DEFAULT_UNKNOWN_BY_LOCATION.get(
49+
location, self.DEFAULT_UNKNOWN
50+
)
51+
)
52+
)
4353
load_kwargs = {"unknown": unknown}
4454
if req is None:
4555
raise ValueError("Must pass req object")
@@ -113,7 +123,7 @@ def use_args(
113123
req: typing.Optional[Request] = None,
114124
*,
115125
location: str = None,
116-
unknown=None,
126+
unknown=core._UNKNOWN_DEFAULT_PARAM,
117127
as_kwargs: bool = False,
118128
validate: Validate = None,
119129
error_status_code: typing.Optional[int] = None,

src/webargs/core.py

Lines changed: 40 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,10 @@
2323
]
2424

2525

26+
# a value used as the default for arguments, so that when `None` is passed, it
27+
# can be distinguished from the default value
28+
_UNKNOWN_DEFAULT_PARAM = "_default"
29+
2630
DEFAULT_VALIDATION_STATUS = 422 # type: int
2731

2832

@@ -97,15 +101,23 @@ class Parser:
97101
etc.
98102
99103
:param str location: Default location to use for data
100-
:param str unknown: Default value for ``unknown`` in ``parse``,
101-
``use_args``, and ``use_kwargs``
104+
:param str unknown: A default value to pass for ``unknown`` when calling the
105+
schema's ``load`` method. Defaults to EXCLUDE for non-body
106+
locations and RAISE for request bodies. Pass ``None`` to use the
107+
schema's setting instead.
102108
:param callable error_handler: Custom error handler function.
103109
"""
104110

105111
#: Default location to check for data
106112
DEFAULT_LOCATION = "json"
107113
#: Default value to use for 'unknown' on schema load
108-
DEFAULT_UNKNOWN = None
114+
DEFAULT_UNKNOWN = ma.EXCLUDE
115+
#: per-location default for 'unknown'
116+
DEFAULT_UNKNOWN_BY_LOCATION = {
117+
"json": ma.RAISE,
118+
"form": ma.RAISE,
119+
"json_or_form": ma.RAISE,
120+
}
109121
#: The marshmallow Schema class to use when creating new schemas
110122
DEFAULT_SCHEMA_CLASS = ma.Schema
111123
#: Default status code to return for validation errors
@@ -126,12 +138,17 @@ class Parser:
126138
}
127139

128140
def __init__(
129-
self, location=None, *, unknown=None, error_handler=None, schema_class=None
141+
self,
142+
location=None,
143+
*,
144+
unknown=_UNKNOWN_DEFAULT_PARAM,
145+
error_handler=None,
146+
schema_class=None
130147
):
131148
self.location = location or self.DEFAULT_LOCATION
132149
self.error_callback = _callable_or_raise(error_handler)
133150
self.schema_class = schema_class or self.DEFAULT_SCHEMA_CLASS
134-
self.unknown = unknown or self.DEFAULT_UNKNOWN
151+
self.unknown = unknown
135152

136153
def _get_loader(self, location):
137154
"""Get the loader function for the given location.
@@ -219,7 +236,7 @@ def parse(
219236
req=None,
220237
*,
221238
location=None,
222-
unknown=None,
239+
unknown=_UNKNOWN_DEFAULT_PARAM,
223240
validate=None,
224241
error_status_code=None,
225242
error_headers=None
@@ -235,7 +252,9 @@ def parse(
235252
default, that means one of ``('json', 'query', 'querystring',
236253
'form', 'headers', 'cookies', 'files', 'json_or_form')``.
237254
:param str unknown: A value to pass for ``unknown`` when calling the
238-
schema's ``load`` method.
255+
schema's ``load`` method. Defaults to EXCLUDE for non-body
256+
locations and RAISE for request bodies. Pass ``None`` to use the
257+
schema's setting instead.
239258
:param callable validate: Validation function or list of validation functions
240259
that receives the dictionary of parsed arguments. Validator either returns a
241260
boolean or raises a :exc:`ValidationError`.
@@ -248,8 +267,19 @@ def parse(
248267
"""
249268
req = req if req is not None else self.get_default_request()
250269
location = location or self.location
251-
unknown = unknown or self.unknown
252-
load_kwargs = {"unknown": unknown}
270+
# precedence order: explicit, instance setting, default per location, default
271+
unknown = (
272+
unknown
273+
if unknown != _UNKNOWN_DEFAULT_PARAM
274+
else (
275+
self.unknown
276+
if self.unknown != _UNKNOWN_DEFAULT_PARAM
277+
else self.DEFAULT_UNKNOWN_BY_LOCATION.get(
278+
location, self.DEFAULT_UNKNOWN
279+
)
280+
)
281+
)
282+
load_kwargs = {"unknown": unknown} if unknown else {}
253283
if req is None:
254284
raise ValueError("Must pass req object")
255285
data = None
@@ -311,7 +341,7 @@ def use_args(
311341
req=None,
312342
*,
313343
location=None,
314-
unknown=None,
344+
unknown=_UNKNOWN_DEFAULT_PARAM,
315345
as_kwargs=False,
316346
validate=None,
317347
error_status_code=None,

src/webargs/flaskparser.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ def user_detail(args, uid):
2323
import flask
2424
from werkzeug.exceptions import HTTPException
2525

26+
import marshmallow as ma
27+
2628
from webargs import core
2729
from webargs.multidictproxy import MultiDictProxy
2830

@@ -48,6 +50,11 @@ def is_json_request(req):
4850
class FlaskParser(core.Parser):
4951
"""Flask request argument parser."""
5052

53+
DEFAULT_UNKNOWN_BY_LOCATION = {
54+
"view_args": ma.RAISE,
55+
"path": ma.RAISE,
56+
**core.Parser.DEFAULT_UNKNOWN_BY_LOCATION,
57+
}
5158
__location_map__ = dict(
5259
view_args="load_view_args",
5360
path="load_view_args",

src/webargs/pyramidparser.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ def hello_world(request, args):
3030
from webob.multidict import MultiDict
3131
from pyramid.httpexceptions import exception_response
3232

33+
import marshmallow as ma
34+
3335
from webargs import core
3436
from webargs.core import json
3537
from webargs.multidictproxy import MultiDictProxy
@@ -42,6 +44,11 @@ def is_json_request(req):
4244
class PyramidParser(core.Parser):
4345
"""Pyramid request argument parser."""
4446

47+
DEFAULT_UNKNOWN_BY_LOCATION = {
48+
"matchdict": ma.RAISE,
49+
"path": ma.RAISE,
50+
**core.Parser.DEFAULT_UNKNOWN_BY_LOCATION,
51+
}
4552
__location_map__ = dict(
4653
matchdict="load_matchdict",
4754
path="load_matchdict",

0 commit comments

Comments
 (0)