webargs
webargs copied to clipboard
RFC: Use annotations for parsing arguments
Add a decorator that will parse request arguments from the view function's type annotations.
I've built a working proof of concept of this idea in webargs-starlette.
@app.route("/")
@use_annotations(locations=("query",))
async def index(request, name: str = "World"):
return JSONResponse({"Hello": name})
A marshmallow Schema
is generated from the annotations using Schema.TYPE_MAPPING
to construct fields.
The code for use_annotations
mostly isn't tied to Starlette and could be adapted for AsyncParser
(and core.Parser
when we drop Python 2 support).
I was searching the web looking exactly for a solution to have method signature read by a decorator, parsed from request and fed to the method. This is perfect for when you have a method that you suddenly want to make available on the web (e.g. as a CloudFunction or a Lambda). I would no longer have to change the method to take input from a request object or have to write a redundant declaration of the desired fields when I already have all type hints right there on the method signature.
Hoping to see this in webargs soon.
But... I would prefer a different name. Annotations are a different thing.
Maybe @use_type_hints or @args_from_method_signature
I keep circling back to this to think about it, but with the same concern. How would such a decorator distinguish between type annotations meant for it, vs ones which are just part of a view function?
In particular, if a view function is already decorated with user-defined decorators, it may be receiving a variety of arguments.
I think that for really simple cases like your example it's very nice. But what if instead of
def index(request, name: str = "World")
it's
@get_authenticated_username # passes username
@use_annotations(location="query")
def index(request, username: str, name: str = "World")
or something similar?
Would we simply not support such usages? Maybe that's okay, and just has to be documented, or maybe there's a workaround?
I was just looking at webargs-starlette for inspiration, thinking about whether or not we could do this as part of v7 (and whether there are any backwards-incompatible changes we'd like to make for it, which would make now a good time for it).
I think my above concern is basically a non-issue. First because nothing would force people to use such a feature, but also because we could easily add an optional exclude
list to the annotations helper. My above case can then be solved simply with @use_annotations(location="query", exclude=["username"])
.
Not sure how active this undertaking is, but usability could be improved even further by using PEP593 typing.Annotated[…]
objects, which have been included in python 3.9.
For instance, sqlalchemy uses them for its dataclass integration to augment to allow for more fine-grained configuration than what you can do with a type map.
To elaborate, the objective is that passing e.g. fields.String
as the data type breaks type checking, because clearly we get a string, not an instance of the field type, at runtime.
For instance, the following snippet with vanilla webargs
has type hints correctly reflecting the types possible at runtime:
@bp.route('/accounts/<int:account_id>/json')
@use_kwargs({
"style": fields.String(),
"limit": fields.Int(),
"offset": fields.Int(),
"sort_by": fields.String(data_key="sort", missing="valid_on"),
"sort_order": fields.String(data_key="order", missing="desc"),
"search": fields.String(),
"splitted": fields.Bool(missing=False),
}, location="query")
def accounts_show_json(
account_id: int,
*,
style: str | None = None,
limit: int | None = None,
offset: int | None = None,
sort_by: str,
sort_order: str,
search: str | None = None,
splitted: bool,
): ...
However, clearly the information here is rendundant, so inferring the type information from the annotations is desirable.
Now with webargs-starlette
, in my understanding the code would have to look like this:
@bp.route('/accounts/<int:account_id>/json')
@use_annotations(location="query", exclude={"account_id"})
def accounts_show_json(
account_id: int,
*,
style: str | None = None,
limit: int | None = None,
offset: int | None = None,
sort_by: fields.String(data_key="sort") = "valid_on",
sort_order: fields.String(data_key="order") = "desc",
search: str | None = None,
splitted: bool = False,
):
...
However, as mentioned the above would not type check. PEP593 Annotated
to the rescue:
import typing as t
@bp.route('/accounts/<int:account_id>/json')
@use_annotations(location="query", exclude={"account_id"})
def accounts_show_json(
account_id: int,
*,
style: str | None = None,
limit: int | None = None,
offset: int | None = None,
sort_by: t.Annotated[str, fields.String(data_key="sort")] = "valid_on",
sort_order: t.Annotated[str, fields.String(data_key="order")] = "desc",
search: str | None = None,
splitted: bool = False,
):
...
Note that in the example, I expect default values to be incorporated in the field's missing=
attribute, even if I give a field directly. I did not check whether webargs-starlette
supports this.