Skip to content

Commit

Permalink
Merge pull request #833 from marshmallow-code/support-arg-name
Browse files Browse the repository at this point in the history
Implement arg_name and USE_ARGS_POSITIONAL
  • Loading branch information
sirosen committed Jul 10, 2023
2 parents d364f90 + 37ae010 commit 6c9f9b1
Show file tree
Hide file tree
Showing 5 changed files with 312 additions and 13 deletions.
5 changes: 5 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@ Features:
over the type of the request object. Various framework-specific parsers are
parametrized over their relevant request object classes.

* ``webargs.Parser`` and its subclasses now support passing arguments as a
single keyword argument without expanding the parsed data into its
components. For more details, see advanced docs on
``Argument Passing and arg_name``.

Other changes:

* Type annotations have been improved to allow ``Mapping`` for dict-like
Expand Down
78 changes: 77 additions & 1 deletion docs/advanced.rst
Original file line number Diff line number Diff line change
Expand Up @@ -394,7 +394,6 @@ To add your own parser, extend :class:`Parser <webargs.core.Parser>` and impleme
import re
from webargs import core
from webargs.flaskparser import FlaskParser
Expand Down Expand Up @@ -656,6 +655,83 @@ To reduce boilerplate, you could create shortcuts, like so:
name = json_parsed["name"]
# ...
Argument Passing and ``arg_name``
---------------------------------

.. NOTE::

This section describes behaviors which are planned to change in ``webargs``
version 9. In version 8, behavior will be as follows. In version 9,
``USE_ARGS_POSITIONAL`` will be removed and will always be ``False``.

By default, ``webargs`` provides two ways of passing arguments via decorators,
`Parser.use_args <webargs.core.Parser.use_args>`, and `Parser.use_kwargs <webargs.core.Parser.use_kwargs>`.
``use_args`` passes parsed arguments as positionals, and ``use_kwargs`` expands
dict-like parsed arguments into keyword arguments.

For ``use_args``, the result is that sometimes it is non-obvious which order
arguments will be passed in. Consider the following nearly identical example
snippets:

.. code-block:: python
# correct ordering, top-to-bottom
@use_args({"foo": fields.Int(), "bar": fields.Str()}, location="query")
@use_args({"baz": fields.Str()}, location="json")
def viewfunc(query_args, json_args):
...
# incorrect ordering, bottom-to-top
@use_args({"foo": fields.Int(), "bar": fields.Str()}, location="query")
@use_args({"baz": fields.Str()}, location="json")
def viewfunc(json_args, query_args):
...
To resolve this ambiguity, ``webargs`` version 9 will pass arguments from
``use_args`` as keyword arguments. You can opt-in to this behavior today by
setting ``USE_ARGS_POSITIONAL = False`` on a parser class. This will cause
webargs to pass arguments named ``{location}_args`` for each location used.
For example,

.. code-block:: python
from webargs.flaskparser import FlaskParser
from flask import Flask
class KeywordOnlyParser(FlaskParser):
USE_ARGS_POSITIONAL = False
app = Flask(__name__)
parser = KeywordOnlyParser()
@app.route("/")
@parser.use_args({"foo": fields.Int(), "bar": fields.Str()}, location="query")
@parser.use_args({"baz": fields.Str()}, location="json")
def myview(*, query_args, json_args):
...
You can also customize the names of passed arguments using the ``arg_name``
parameter:

.. code-block:: python
@app.route("/")
@parser.use_args(
{"foo": fields.Int(), "bar": fields.Str()}, location="query", arg_name="query"
)
@parser.use_args({"baz": fields.Str()}, location="json", arg_name="payload")
def myview(*, query, payload):
...
Note that ``arg_name`` is available even on parsers where
``USE_ARGS_POSITIONAL`` is not set.

Next Steps
----------

Expand Down
78 changes: 67 additions & 11 deletions src/webargs/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@

import asyncio
import functools
import typing
import logging
import json
import logging
import typing

import marshmallow as ma
from marshmallow import ValidationError
Expand Down Expand Up @@ -51,6 +51,14 @@
DEFAULT_VALIDATION_STATUS: int = 422


def _record_arg_name(f: typing.Callable[..., typing.Any], argname: str | None) -> None:
if argname is None:
return
if not hasattr(f, "__webargs_argnames__"):
f.__webargs_argnames__ = () # type: ignore[attr-defined]
f.__webargs_argnames__ += (argname,) # type: ignore[attr-defined]


def _iscallable(x) -> bool:
# workaround for
# https://github.com/python/mypy/issues/9778
Expand Down Expand Up @@ -154,6 +162,9 @@ class Parser(typing.Generic[Request]):
DEFAULT_VALIDATION_MESSAGE: str = "Invalid value."
#: field types which should always be treated as if they set `is_multiple=True`
KNOWN_MULTI_FIELDS: list[type] = [ma.fields.List, ma.fields.Tuple]
#: set use_args to use a positional argument (rather than a keyword argument)
# defaults to True, but will become False in a future major version
USE_ARGS_POSITIONAL: bool = True

#: Maps location => method name
__location_map__: dict[str, str | typing.Callable] = {
Expand Down Expand Up @@ -495,13 +506,19 @@ def _update_args_kwargs(
kwargs: dict[str, typing.Any],
parsed_args: tuple,
as_kwargs: bool,
arg_name: str | None,
) -> tuple[tuple, typing.Mapping]:
"""Update args or kwargs with parsed_args depending on as_kwargs"""
if as_kwargs:
# expand parsed_args into kwargs
kwargs.update(parsed_args)
else:
# Add parsed_args after other positional arguments
args += (parsed_args,)
if arg_name:
# add parsed_args as a specific kwarg
kwargs[arg_name] = parsed_args
else:
# Add parsed_args after other positional arguments
args += (parsed_args,)
return args, kwargs

def use_args(
Expand All @@ -512,6 +529,7 @@ def use_args(
location: str | None = None,
unknown: str | None = _UNKNOWN_DEFAULT_PARAM,
as_kwargs: bool = False,
arg_name: str | None = None,
validate: ValidateArg = None,
error_status_code: int | None = None,
error_headers: typing.Mapping[str, str] | None = None,
Expand All @@ -522,8 +540,8 @@ def use_args(
@app.route('/echo', methods=['get', 'post'])
@parser.use_args({'name': fields.Str()}, location="querystring")
def greet(args):
return 'Hello ' + args['name']
def greet(querystring_args):
return 'Hello ' + querystring_args['name']
:param argmap: Either a `marshmallow.Schema`, a `dict`
of argname -> `marshmallow.fields.Field` pairs, or a callable
Expand All @@ -532,6 +550,8 @@ def greet(args):
:param str unknown: A value to pass for ``unknown`` when calling the
schema's ``load`` method.
:param bool as_kwargs: Whether to insert arguments as keyword arguments.
:param str arg_name: Keyword argument name to use for arguments. Mutually
exclusive with as_kwargs.
:param callable validate: Validation function that receives the dictionary
of parsed arguments. If the function returns ``False``, the parser
will raise a :exc:`ValidationError`.
Expand All @@ -542,6 +562,12 @@ def greet(args):
"""
location = location or self.location
request_obj = req

if arg_name is not None and as_kwargs:
raise ValueError("arg_name and as_kwargs are mutually exclusive")
if arg_name is None and not self.USE_ARGS_POSITIONAL:
arg_name = f"{location}_args"

# Optimization: If argmap is passed as a dictionary, we only need
# to generate a Schema once
if isinstance(argmap, typing.Mapping):
Expand All @@ -552,6 +578,17 @@ def greet(args):
def decorator(func: typing.Callable) -> typing.Callable:
req_ = request_obj

# check at decoration time that a unique name is being used
# (no arg_name conflicts)
if arg_name is not None and not as_kwargs:
existing_arg_names = getattr(func, "__webargs_argnames__", ())
if arg_name in existing_arg_names:
raise ValueError(
f"Attempted to pass `arg_name='{arg_name}'` via use_args() but "
"that name was already used. If this came from stacked webargs "
"decorators, try setting `arg_name` to distinguish usages."
)

if asyncio.iscoroutinefunction(func):

@functools.wraps(func)
Expand All @@ -571,7 +608,7 @@ async def wrapper(*args, **kwargs):
error_headers=error_headers,
)
args, kwargs = self._update_args_kwargs(
args, kwargs, parsed_args, as_kwargs
args, kwargs, parsed_args, as_kwargs, arg_name
)
return await func(*args, **kwargs)

Expand All @@ -594,16 +631,27 @@ def wrapper(*args, **kwargs):
error_headers=error_headers,
)
args, kwargs = self._update_args_kwargs(
args, kwargs, parsed_args, as_kwargs
args, kwargs, parsed_args, as_kwargs, arg_name
)
return func(*args, **kwargs)

wrapper.__wrapped__ = func # type: ignore
_record_arg_name(wrapper, arg_name)
return wrapper

return decorator

def use_kwargs(self, *args, **kwargs) -> typing.Callable:
def use_kwargs(
self,
argmap: ArgMap,
req: Request | None = None,
*,
location: str | None = None,
unknown: str | None = _UNKNOWN_DEFAULT_PARAM,
validate: ValidateArg = None,
error_status_code: int | None = None,
error_headers: typing.Mapping[str, str] | None = None,
) -> typing.Callable[..., typing.Callable]:
"""Decorator that injects parsed arguments into a view function or method
as keyword arguments.
Expand All @@ -618,8 +666,16 @@ def greet(name):
Receives the same ``args`` and ``kwargs`` as :meth:`use_args`.
"""
kwargs["as_kwargs"] = True
return self.use_args(*args, **kwargs)
return self.use_args(
argmap,
req=req,
as_kwargs=True,
location=location,
unknown=unknown,
validate=validate,
error_status_code=error_status_code,
error_headers=error_headers,
)

def location_loader(self, name: str) -> typing.Callable[[C], C]:
"""Decorator that registers a function for loading a request location.
Expand Down
11 changes: 10 additions & 1 deletion src/webargs/pyramidparser.py
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ def use_args(
location=core.Parser.DEFAULT_LOCATION,
unknown=None,
as_kwargs=False,
arg_name=None,
validate=None,
error_status_code=None,
error_headers=None,
Expand All @@ -141,6 +142,8 @@ def use_args(
:param str unknown: A value to pass for ``unknown`` when calling the
schema's ``load`` method.
:param bool as_kwargs: Whether to insert arguments as keyword arguments.
:param str arg_name: Keyword argument name to use for arguments. Mutually
exclusive with as_kwargs.
:param callable validate: Validation function that receives the dictionary
of parsed arguments. If the function returns ``False``, the parser
will raise a :exc:`ValidationError`.
Expand All @@ -150,6 +153,12 @@ def use_args(
a `ValidationError` is raised.
"""
location = location or self.location

if arg_name is not None and as_kwargs:
raise ValueError("arg_name and as_kwargs are mutually exclusive")
if arg_name is None and not self.USE_ARGS_POSITIONAL:
arg_name = f"{location}_args"

# Optimization: If argmap is passed as a dictionary, we only need
# to generate a Schema once
if isinstance(argmap, Mapping):
Expand All @@ -176,7 +185,7 @@ def wrapper(obj, *args, **kwargs):
error_headers=error_headers,
)
args, kwargs = self._update_args_kwargs(
args, kwargs, parsed_args, as_kwargs
args, kwargs, parsed_args, as_kwargs, arg_name
)
return func(obj, *args, **kwargs)

Expand Down

0 comments on commit 6c9f9b1

Please sign in to comment.