Skip to content

Commit 4785d3e

Browse files
authored
Merge pull request #544 from sirosen/location-default-unknown
[7.x] Refactor support for 'unknown' with location map
2 parents dab07ad + 205791e commit 4785d3e

File tree

14 files changed

+279
-54
lines changed

14 files changed

+279
-54
lines changed

CHANGELOG.rst

Lines changed: 23 additions & 11 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``,
31+
``Parser.use_kwargs``, and 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,9 @@ 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.
5252

5353
Usages are varied, but include
5454

@@ -57,15 +57,27 @@ Usages are varied, but include
5757
import marshmallow as ma
5858
from webargs.flaskparser import FlaskParser
5959
60-
parser = FlaskParser(unknown=ma.INCLUDE)
61-
6260
# as well as...
6361
class MyParser(FlaskParser):
64-
DEFAULT_UNKNOWN = ma.INCLUDE
62+
DEFAULT_UNKNOWN_BY_LOCATION = {"query": ma.INCLUDE}
6563
6664
6765
parser = MyParser()
6866
67+
Setting the ``unknown`` value for a Parser instance has higher precedence. So
68+
69+
.. code-block:: python
70+
71+
parser = MyParser(unknown=ma.RAISE)
72+
73+
will always pass ``RAISE``, even when the location is ``query``.
74+
75+
* By default, webargs will pass ``unknown=EXCLUDE`` for all locations except
76+
for request bodies (``json``, ``form``, and ``json_or_form``) and path
77+
parameters. Request bodies and path parameters will pass ``unknown=RAISE``.
78+
This behavior is defined by the default value for
79+
``DEFAULT_UNKNOWN_BY_LOCATION``.
80+
6981
Changes:
7082

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

docs/advanced.rst

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,122 @@ 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`, `json_or_form`, `path`, or `path`. In those cases,
155+
it uses `unknown=marshmallow.RAISE` instead.
156+
157+
You can change these defaults by overriding `DEFAULT_UNKNOWN_BY_LOCATION`.
158+
This is a mapping of locations to values to pass.
159+
160+
For example,
161+
162+
.. code-block:: python
163+
164+
from flask import Flask
165+
from marshmallow import EXCLUDE, fields
166+
from webargs.flaskparser import FlaskParser
167+
168+
app = Flask(__name__)
169+
170+
171+
class Parser(FlaskParser):
172+
DEFAULT_UNKNOWN_BY_LOCATION = {"query": EXCLUDE}
173+
174+
175+
parser = Parser()
176+
177+
178+
# location is "query", which is listed in DEFAULT_UNKNOWN_BY_LOCATION,
179+
# so EXCLUDE will be used
180+
@app.route("/", methods=["GET"])
181+
@parser.use_args({"foo": fields.Int()}, location="query")
182+
def get(self, args):
183+
return f"foo x 2 = {args['foo'] * 2}"
184+
185+
186+
# location is "json", which is not in DEFAULT_UNKNOWN_BY_LOCATION,
187+
# so no value will be passed for `unknown`
188+
@app.route("/", methods=["POST"])
189+
@parser.use_args({"foo": fields.Int(), "bar": fields.Int()}, location="json")
190+
def post(self, args):
191+
return f"foo x bar = {args['foo'] * args['bar']}"
192+
193+
194+
You can also define a default at parser instantiation, which will take
195+
precedence over these defaults, as in
196+
197+
.. code-block:: python
198+
199+
from marshmallow import INCLUDE
200+
201+
parser = Parser(unknown=INCLUDE)
202+
203+
# because `unknown` is set on the parser, `DEFAULT_UNKNOWN_BY_LOCATION` has
204+
# effect and `INCLUDE` will always be used
205+
@app.route("/", methods=["POST"])
206+
@parser.use_args({"foo": fields.Int(), "bar": fields.Int()}, location="json")
207+
def post(self, args):
208+
unexpected_args = [k for k in args.keys() if k not in ("foo", "bar")]
209+
return f"foo x bar = {args['foo'] * args['bar']}; unexpected args={unexpected_args}"
210+
211+
Using Schema-Specfied `unknown`
212+
+++++++++++++++++++++++++++++++
213+
214+
If you wish to use the value of `unknown` specified by a schema, simply pass
215+
``unknown=None``. This will disable webargs' automatic passing of values for
216+
``unknown``. For example,
217+
218+
.. code-block:: python
219+
220+
from flask import Flask
221+
from marshmallow import Schema, fields, EXCLUDE, missing
222+
from webargs.flaskparser import use_args
223+
224+
225+
class RectangleSchema(Schema):
226+
length = fields.Float()
227+
width = fields.Float()
228+
229+
class Meta:
230+
unknown = EXCLUDE
231+
232+
233+
app = Flask(__name__)
234+
235+
# because unknown=None was passed, no value is passed during schema loading
236+
# as a result, the schema's behavior (EXCLUDE) is used
237+
@app.route("/", methods=["POST"])
238+
@use_args(RectangleSchema(), location="json", unknown=None)
239+
def get(self, args):
240+
return f"area = {args['length'] * args['width']}"
241+
242+
243+
You can also set ``unknown=None`` when instantiating a parser to make this
244+
behavior the default for a parser.
245+
246+
131247
When to avoid `use_kwargs`
132248
--------------------------
133249

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: 11 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,15 @@ 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(location)
49+
)
50+
)
4351
load_kwargs = {"unknown": unknown}
4452
if req is None:
4553
raise ValueError("Must pass req object")
@@ -113,7 +121,7 @@ def use_args(
113121
req: typing.Optional[Request] = None,
114122
*,
115123
location: str = None,
116-
unknown=None,
124+
unknown=core._UNKNOWN_DEFAULT_PARAM,
117125
as_kwargs: bool = False,
118126
validate: Validate = None,
119127
error_status_code: typing.Optional[int] = None,

src/webargs/core.py

Lines changed: 42 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,27 @@ 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+
# on a per-location basis
115+
DEFAULT_UNKNOWN_BY_LOCATION = {
116+
"json": ma.RAISE,
117+
"form": ma.RAISE,
118+
"json_or_form": ma.RAISE,
119+
"querystring": ma.EXCLUDE,
120+
"query": ma.EXCLUDE,
121+
"headers": ma.EXCLUDE,
122+
"cookies": ma.EXCLUDE,
123+
"files": ma.EXCLUDE,
124+
}
109125
#: The marshmallow Schema class to use when creating new schemas
110126
DEFAULT_SCHEMA_CLASS = ma.Schema
111127
#: Default status code to return for validation errors
@@ -126,12 +142,17 @@ class Parser:
126142
}
127143

128144
def __init__(
129-
self, location=None, *, unknown=None, error_handler=None, schema_class=None
145+
self,
146+
location=None,
147+
*,
148+
unknown=_UNKNOWN_DEFAULT_PARAM,
149+
error_handler=None,
150+
schema_class=None
130151
):
131152
self.location = location or self.DEFAULT_LOCATION
132153
self.error_callback = _callable_or_raise(error_handler)
133154
self.schema_class = schema_class or self.DEFAULT_SCHEMA_CLASS
134-
self.unknown = unknown or self.DEFAULT_UNKNOWN
155+
self.unknown = unknown
135156

136157
def _get_loader(self, location):
137158
"""Get the loader function for the given location.
@@ -219,7 +240,7 @@ def parse(
219240
req=None,
220241
*,
221242
location=None,
222-
unknown=None,
243+
unknown=_UNKNOWN_DEFAULT_PARAM,
223244
validate=None,
224245
error_status_code=None,
225246
error_headers=None
@@ -235,7 +256,9 @@ def parse(
235256
default, that means one of ``('json', 'query', 'querystring',
236257
'form', 'headers', 'cookies', 'files', 'json_or_form')``.
237258
:param str unknown: A value to pass for ``unknown`` when calling the
238-
schema's ``load`` method.
259+
schema's ``load`` method. Defaults to EXCLUDE for non-body
260+
locations and RAISE for request bodies. Pass ``None`` to use the
261+
schema's setting instead.
239262
:param callable validate: Validation function or list of validation functions
240263
that receives the dictionary of parsed arguments. Validator either returns a
241264
boolean or raises a :exc:`ValidationError`.
@@ -248,8 +271,17 @@ def parse(
248271
"""
249272
req = req if req is not None else self.get_default_request()
250273
location = location or self.location
251-
unknown = unknown or self.unknown
252-
load_kwargs = {"unknown": unknown}
274+
# precedence order: explicit, instance setting, default per location
275+
unknown = (
276+
unknown
277+
if unknown != _UNKNOWN_DEFAULT_PARAM
278+
else (
279+
self.unknown
280+
if self.unknown != _UNKNOWN_DEFAULT_PARAM
281+
else self.DEFAULT_UNKNOWN_BY_LOCATION.get(location)
282+
)
283+
)
284+
load_kwargs = {"unknown": unknown} if unknown else {}
253285
if req is None:
254286
raise ValueError("Must pass req object")
255287
data = None
@@ -311,7 +343,7 @@ def use_args(
311343
req=None,
312344
*,
313345
location=None,
314-
unknown=None,
346+
unknown=_UNKNOWN_DEFAULT_PARAM,
315347
as_kwargs=False,
316348
validate=None,
317349
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)