diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e579c22..3eb6b9c 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,28 +1,21 @@ repos: -- repo: https://github.com/asottile/pyupgrade - rev: v1.26.0 - hooks: - - id: pyupgrade - args: ["--py3-plus"] -- repo: https://github.com/psf/black - rev: 19.10b0 +- repo: https://github.com/python/black + rev: 19.3b0 hooks: - id: black - args: ["--target-version", "py35"] language_version: python3 - repo: https://gitlab.com/pycqa/flake8 - rev: 3.7.9 + rev: 3.7.8 hooks: - id: flake8 - additional_dependencies: [flake8-bugbear==20.1.0] + additional_dependencies: ['flake8-bugbear==19.8.0; python_version >= "3.5"'] - repo: https://github.com/asottile/blacken-docs - rev: v1.5.0-1 + rev: v1.3.0 hooks: - id: blacken-docs - additional_dependencies: [black==19.10b0] - args: ["--target-version", "py35"] + additional_dependencies: [black==19.3b0] - repo: https://github.com/pre-commit/mirrors-mypy - rev: v0.761 + rev: v0.730 hooks: - id: mypy language_version: python3 diff --git a/AUTHORS.rst b/AUTHORS.rst index 8cde5c4..b1c8efd 100644 --- a/AUTHORS.rst +++ b/AUTHORS.rst @@ -5,48 +5,39 @@ Lead ---- -* Steven Loria `@sloria `_ -* Jérôme Lafréchoux `@lafrech `_ +* Steven Loria +* Jérôme Lafréchoux Contributors (chronological) ---------------------------- -* Steven Manuatu `@venuatu `_ -* Javier Santacruz `@jvrsantacruz `_ -* Josh Carp `@jmcarp `_ -* `@philtay `_ -* Andriy Yurchuk `@Ch00k `_ -* Stas Sușcov `@stas `_ -* Josh Johnston `@Trii `_ -* Rory Hart `@hartror `_ -* Jace Browning `@jacebrowning `_ -* marcellarius `@marcellarius `_ -* Damian Heard `@DamianHeard `_ -* Daniel Imhoff `@dwieeb `_ -* `@immerrr `_ -* Brett Higgins `@brettdh `_ -* Vlad Frolov `@frol `_ -* Tuukka Mustonen `@tuukkamustonen `_ -* Francois-Xavier Darveau `@EFF `_ -* Jérôme Lafréchoux `@lafrech `_ -* `@DmitriyS `_ -* Svetlozar Argirov `@zaro `_ -* Florian S. `@nebularazer `_ -* `@daniel98321 `_ -* `@Itayazolay `_ -* `@Reskov `_ -* `@cedzz `_ -* F. Moukayed (כוכב) `@kochab `_ -* Xiaoyu Lee `@lee3164 `_ -* Jonathan Angelo `@jangelo `_ -* `@zhenhua32 `_ -* Martin Roy `@lindycoder `_ -* Kubilay Kocak `@koobs `_ -* Stephen Rosen `@sirosen `_ -* `@dodumosu `_ -* Nate Dellinger `@Nateyo `_ -* Karthikeyan Singaravelan `@tirkarthi `_ -* Sami Salonen `@suola `_ -* Tim Gates `@timgates42 `_ -* Lefteris Karapetsas `@lefterisjp `_ -* Utku Gultopu `@ugultopu `_ +* @venuatu +* Javier Santacruz @jvrsantacruz +* Josh Carp +* @philtay +* Andriy Yurchuk +* Stas Sușcov +* Josh Johnston +* Rory Hart +* Jace Browning +* @marcellarius +* Damian Heard +* Daniel Imhoff +* immerrr +* Brett Higgins +* Vlad Frolov +* Tuukka Mustonen +* Francois-Xavier Darveau +* Jérôme Lafréchoux +* @DmitriyS +* Svetlozar Argirov +* Florian S. +* @daniel98321 +* @Itayazolay +* @Reskov +* @cedzz +* F. Moukayed (כוכב) +* Xiaoyu Lee +* Jonathan Angelo +* @zhenhua32 +* Martin Roy diff --git a/CHANGELOG.rst b/CHANGELOG.rst index f72d2b0..e33d23d 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,165 +1,12 @@ Changelog --------- -6.1.0 (2020-04-05) -****************** - -Features: - -* Add ``fields.DelimitedTuple`` when using marshmallow 3. This behaves as a - combination of ``fields.DelimitedList`` and ``marshmallow.fields.Tuple``. It - takes an iterable of fields, plus a delimiter (defaults to ``,``), and parses - delimiter-separated strings into tuples. (:pr:`509`) - -* Add ``__str__`` and ``__repr__`` to MultiDictProxy to make it easier to work - with (:pr:`488`) - -Support: - -* Various docs updates (:pr:`482`, :pr:`486`, :pr:`489`, :pr:`498`, :pr:`508`). - Thanks :user:`lefterisjp`, :user:`timgates42`, and :user:`ugultopu` for the PRs. - - -6.0.0 (2020-02-27) -****************** - -Features: - -* ``FalconParser``: Pass request content length to ``req.stream.read`` to - provide compatibility with ``falcon.testing`` (:pr:`477`). - Thanks :user:`suola` for the PR. - -* *Backwards-incompatible*: Factorize the ``use_args`` / ``use_kwargs`` branch - in all parsers. When ``as_kwargs`` is ``False``, arguments are now - consistently appended to the arguments list by the ``use_args`` decorator. - Before this change, the ``PyramidParser`` would prepend the argument list on - each call to ``use_args``. Pyramid view functions must reverse the order of - their arguments. (:pr:`478`) - -6.0.0b8 (2020-02-16) -******************** - -Refactoring: - -* *Backwards-incompatible*: Use keyword-only arguments (:pr:`472`). - -6.0.0b7 (2020-02-14) -******************** - -Features: - -* *Backwards-incompatible*: webargs will rewrite the error messages in - ValidationErrors to be namespaced under the location which raised the error. - The `messages` field on errors will therefore be one layer deeper with a - single top-level key. - -6.0.0b6 (2020-01-31) -******************** - -Refactoring: - -* Remove the cache attached to webargs parsers. Due to changes between webargs - v5 and v6, the cache is no longer considered useful. - -Other changes: - -* Import ``Mapping`` from ``collections.abc`` in pyramidparser.py (:pr:`471`). - Thanks :user:`tirkarthi` for the PR. - -6.0.0b5 (2020-01-30) -******************** - -Refactoring: - -* *Backwards-incompatible*: `DelimitedList` now requires that its input be a - string and always serializes as a string. It can still serialize and deserialize - using another field, e.g. `DelimitedList(Int())` is still valid and requires - that the values in the list parse as ints. - -6.0.0b4 (2020-01-28) -******************** - -Bug fixes: - -* :cve:`CVE-2020-7965`: Don't attempt to parse JSON if request's content type is mismatched - (bugfix from 5.5.3). - -6.0.0b3 (2020-01-21) -******************** - -Features: - -* *Backwards-incompatible*: Support Falcon 2.0. Drop support for Falcon 1.x - (:pr:`459`). Thanks :user:`dodumosu` and :user:`Nateyo` for the PR. - -6.0.0b2 (2020-01-07) -******************** - -Other changes: - -* *Backwards-incompatible*: Drop support for Python 2 (:issue:`440`). - Thanks :user:`hugovk` for the PR. - -6.0.0b1 (2020-01-06) -******************** - -Features: - -* *Backwards-incompatible*: Schemas will now load all data from a location, not - only data specified by fields. As a result, schemas with validators which - examine the full input data may change in behavior. The `unknown` parameter - on schemas may be used to alter this. For example, - `unknown=marshmallow.EXCLUDE` will produce a behavior similar to webargs v5. - -Bug fixes: - -* *Backwards-incompatible*: All parsers now require the Content-Type to be set - correctly when processing JSON request bodies. This impacts ``DjangoParser``, - ``FalconParser``, ``FlaskParser``, and ``PyramidParser`` - -Refactoring: - -* *Backwards-incompatible*: Schema fields may not specify a location any - longer, and `Parser.use_args` and `Parser.use_kwargs` now accept `location` - (singular) instead of `locations` (plural). Instead of using a single field or - schema with multiple `locations`, users are recommended to make multiple - calls to `use_args` or `use_kwargs` with a distinct schema per location. For - example, code should be rewritten like this: - -.. code-block:: python - - # webargs 5.x and older - @parser.use_args( - { - "q1": ma.fields.Int(location="query"), - "q2": ma.fields.Int(location="query"), - "h1": ma.fields.Int(location="headers"), - }, - locations=("query", "headers"), - ) - def foo(q1, q2, h1): - ... - - - # webargs 6.x - @parser.use_args({"q1": ma.fields.Int(), "q2": ma.fields.Int()}, location="query") - @parser.use_args({"h1": ma.fields.Int()}, location="headers") - def foo(q1, q2, h1): - ... - -* The `location_handler` decorator has been removed and replaced with - `location_loader`. `location_loader` serves the same purpose (letting you - write custom hooks for loading data) but its expected method signature is - different. See the docs on `location_loader` for proper usage. - -Thanks :user:`sirosen` for the PR! - 5.5.3 (2020-01-28) ****************** Bug fixes: -* :cve:`CVE-2020-7965`: Don't attempt to parse JSON if request's content type is mismatched. +* :cve:`CVE-2020-7965`: Don't attempt to parse JSON if the request's Content-Type is mismatched. 5.5.2 (2019-10-06) ****************** diff --git a/LICENSE b/LICENSE index f79bc18..1fa8cbb 100644 --- a/LICENSE +++ b/LICENSE @@ -1,4 +1,4 @@ -Copyright 2014-2020 Steven Loria and contributors +Copyright 2014-2019 Steven Loria and contributors Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/MANIFEST.in b/MANIFEST.in index 7d4e712..7d61281 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,4 +1,2 @@ -graft tests include LICENSE include *.rst -include tox.ini diff --git a/README.rst b/README.rst index 6dde7ca..da409f5 100644 --- a/README.rst +++ b/README.rst @@ -36,7 +36,7 @@ @app.route("/") - @use_args({"name": fields.Str(required=True)}, location="query") + @use_args({"name": fields.Str(required=True)}) def index(args): return "Hello " + args["name"] @@ -54,7 +54,7 @@ pip install -U webargs -webargs supports Python >= 3.5. +webargs supports Python >= 2.7 or >= 3.5. Documentation diff --git a/azure-pipelines.yml b/azure-pipelines.yml index 2d1281d..c80853c 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -26,25 +26,33 @@ parameters: toxenvs: - lint + - py27-marshmallow2 - py35-marshmallow2 - py35-marshmallow3 + - py36-marshmallow2 - py36-marshmallow3 + - py37-marshmallow2 - py37-marshmallow3 - - py38-marshmallow2 - - py38-marshmallow3 - - - py38-marshmallowdev + - py37-marshmallowdev - docs os: linux -# Build wheels +# Build separate wheels for python 2 and 3 - template: job--pypi-release.yml@sloria parameters: python: "3.7" distributions: "sdist bdist_wheel" + name_postfix: "_py3" dependsOn: - tox_linux +- template: job--pypi-release.yml@sloria + parameters: + python: "2.7" + distributions: "bdist_wheel" + name_postfix: "_py2" + dependsOn: + - tox_linux diff --git a/docs/advanced.rst b/docs/advanced.rst index e29c9cd..01b0417 100644 --- a/docs/advanced.rst +++ b/docs/advanced.rst @@ -6,7 +6,7 @@ Custom Location Handlers ------------------------ -To add your own custom location handler, write a function that receives a request, and a :class:`Schema `, then decorate that function with :func:`Parser.location_loader `. +To add your own custom location handler, write a function that receives a request, an argument name, and a :class:`Field `, then decorate that function with :func:`Parser.location_handler `. .. code-block:: python @@ -15,77 +15,16 @@ from webargs.flaskparser import parser - @parser.location_loader("data") - def load_data(request, schema): - return request.data + @parser.location_handler("data") + def parse_data(request, name, field): + return request.data.get(name) # Now 'data' can be specified as a location - @parser.use_args({"per_page": fields.Int()}, location="data") + @parser.use_args({"per_page": fields.Int()}, locations=("data",)) def posts(args): return "displaying {} posts".format(args["per_page"]) - -.. NOTE:: - - The schema is passed so that it can be used to wrap multidict types and - unpack List fields correctly. If you are writing a loader for a multidict - type, consider looking at - :class:`MultiDictProxy ` for an - example of how to do this. - -"meta" Locations -~~~~~~~~~~~~~~~~ - -You can define your own locations which mix data from several existing -locations. - -The `json_or_form` location does this -- first trying to load data as JSON and -then falling back to a form body -- and its implementation is quite simple: - - -.. code-block:: python - - def load_json_or_form(self, req, schema): - """Load data from a request, accepting either JSON or form-encoded - data. - - The data will first be loaded as JSON, and, if that fails, it will be - loaded as a form post. - """ - data = self.load_json(req, schema) - if data is not missing: - return data - return self.load_form(req, schema) - - -You can imagine your own locations with custom behaviors like this. -For example, to mix query parameters and form body data, you might write the -following: - -.. code-block:: python - - from webargs import fields - from webargs.multidictproxy import MultiDictProxy - from webargs.flaskparser import parser - - - @parser.location_loader("query_and_form") - def load_data(request, schema): - # relies on the Flask (werkzeug) MultiDict type's implementation of - # these methods, but when you're extending webargs, you may know things - # about your framework of choice - newdata = request.args.copy() - newdata.update(request.form) - return MultiDictProxy(newdata, schema) - - - # Now 'query_and_form' means you can send these values in either location, - # and they will be *mixed* together into a new dict to pass to your schema - @parser.use_args({"favorite_food": fields.String()}, location="query_and_form") - def set_favorite_food(args): - ... # do stuff - return "your favorite food is now set to {}".format(args["favorite_food"]) marshmallow Integration ----------------------- @@ -125,7 +64,7 @@ # You can add additional parameters - @use_kwargs({"posts_per_page": fields.Int(missing=10)}, location="query") + @use_kwargs({"posts_per_page": fields.Int(missing=10, location="query")}) @use_args(UserSchema()) def profile_posts(args, posts_per_page): username = args["username"] @@ -134,42 +73,8 @@ .. warning:: If you're using marshmallow 2, you should always set ``strict=True`` (either as a ``class Meta`` option or in the Schema's constructor) when passing a schema to webargs. This will ensure that the parser's error handler is invoked when expected. - -When to avoid `use_kwargs` --------------------------- - -Any `Schema ` passed to `use_kwargs ` MUST deserialize to a dictionary of data. -If your schema has a `post_load ` method -that returns a non-dictionary, -you should use `use_args ` instead. - -.. code-block:: python - - from marshmallow import Schema, fields, post_load - from webargs.flaskparser import use_args - - - class Rectangle: - def __init__(self, length, width): - self.length = length - self.width = width - - - class RectangleSchema(Schema): - length = fields.Float() - width = fields.Float() - - @post_load - def make_object(self, data, **kwargs): - return Rectangle(**data) - - - @use_args(RectangleSchema) - def post(self, rect: Rectangle): - return f"Area: {rect.length * rect.width}" - -Packages such as `marshmallow-sqlalchemy `_ and `marshmallow-dataclass `_ generate schemas that deserialize to non-dictionary objects. -Therefore, `use_args ` should be used with those schemas. +.. warning:: + Any `Schema ` passed to `use_kwargs ` MUST deserialize to a dictionary of data. Keep this in mind when writing `post_load ` methods. Schema Factories @@ -272,12 +177,12 @@ cube = args["cube"] # ... -.. _custom-loaders: +.. _custom-parsers: Custom Parsers -------------- -To add your own parser, extend :class:`Parser ` and implement the `load_*` method(s) you need to override. For example, here is a custom Flask parser that handles nested query string arguments. +To add your own parser, extend :class:`Parser ` and implement the `parse_*` method(s) you need to override. For example, here is a custom Flask parser that handles nested query string arguments. .. code-block:: python @@ -306,8 +211,8 @@ } """ - def load_querystring(self, req, schema): - return _structure_dict(req.args) + def parse_querystring(self, req, name, field): + return core.get_value(_structure_dict(req.args), name, field) def _structure_dict(dict_): @@ -370,7 +275,7 @@ @app.route("/profile/", methods=["patch"]) - @use_args(PatchSchema(many=True)) + @use_args(PatchSchema(many=True), locations=("json",)) def patch_blog(args): """Implements JSON Patch for the user profile @@ -385,14 +290,48 @@ Mixing Locations ---------------- -Arguments for different locations can be specified by passing ``location`` to each `use_args ` call: - -.. code-block:: python - - # "json" is the default, used explicitly below +Arguments for different locations can be specified by passing ``location`` to each field individually: + +.. code-block:: python + @app.route("/stacked", methods=["POST"]) - @use_args({"page": fields.Int(), "q": fields.Str()}, location="query") - @use_args({"name": fields.Str()}, location="json") + @use_args( + { + "page": fields.Int(location="query"), + "q": fields.Str(location="query"), + "name": fields.Str(location="json"), + } + ) + def viewfunc(args): + page = args["page"] + # ... + +Alternatively, you can pass multiple locations to `use_args `: + +.. code-block:: python + + @app.route("/stacked", methods=["POST"]) + @use_args( + {"page": fields.Int(), "q": fields.Str(), "name": fields.Str()}, + locations=("query", "json"), + ) + def viewfunc(args): + page = args["page"] + # ... + +However, this allows ``page`` and ``q`` to be passed in the request body and ``name`` to be passed as a query parameter. + +To restrict the arguments to single locations without having to pass ``location`` to every field, you can call the `use_args ` multiple times: + +.. code-block:: python + + query_args = {"page": fields.Int(), "q": fields.Int()} + json_args = {"name": fields.Str()} + + + @app.route("/stacked", methods=["POST"]) + @use_args(query_args, locations=("query",)) + @use_args(json_args, locations=("json",)) def viewfunc(query_parsed, json_parsed): page = query_parsed["page"] name = json_parsed["name"] @@ -404,12 +343,12 @@ import functools - query = functools.partial(use_args, location="query") - body = functools.partial(use_args, location="json") - - - @query({"page": fields.Int(), "q": fields.Int()}) - @body({"name": fields.Str()}) + query = functools.partial(use_args, locations=("query",)) + body = functools.partial(use_args, locations=("json",)) + + + @query(query_args) + @body(json_args) def viewfunc(query_parsed, json_parsed): page = query_parsed["page"] name = json_parsed["name"] diff --git a/docs/api.rst b/docs/api.rst index 6499e47..d9a4ad1 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -15,14 +15,6 @@ .. automodule:: webargs.fields :members: Nested, DelimitedList - - -webargs.multidictproxy ----------------------- - -.. automodule:: webargs.multidictproxy - :members: - webargs.asyncparser ------------------- diff --git a/docs/conf.py b/docs/conf.py index 5db7a8d..880d799 100755 --- a/docs/conf.py +++ b/docs/conf.py @@ -1,3 +1,4 @@ +# -*- coding: utf-8 -*- import datetime as dt import sys import os @@ -36,8 +37,8 @@ html_domain_indices = False source_suffix = ".rst" -project = "webargs" -copyright = "2014-{:%Y}, Steven Loria and contributors".format(dt.datetime.utcnow()) +project = u"webargs" +copyright = u"2014-{0:%Y}, Steven Loria and contributors".format(dt.datetime.utcnow()) version = release = webargs.__version__ templates_path = ["_templates"] exclude_patterns = ["_build"] diff --git a/docs/framework_support.rst b/docs/framework_support.rst index f33df31..c1b2f9a 100644 --- a/docs/framework_support.rst +++ b/docs/framework_support.rst @@ -22,9 +22,9 @@ @app.route("/user/") - @use_args({"per_page": fields.Int()}, location="query") + @use_args({"per_page": fields.Int()}) def user_detail(args, uid): - return ("The user page for user {uid}, showing {per_page} posts.").format( + return ("The user page for user {uid}, " "showing {per_page} posts.").format( uid=uid, per_page=args["per_page"] ) @@ -64,7 +64,7 @@ @app.route("/greeting//") - @use_args({"name": fields.Str()}, location="view_args") + @use_args({"name": fields.Str(location="view_args")}) def greeting(args, **kwargs): return "Hello {}".format(args["name"]) @@ -95,7 +95,7 @@ } - @use_args(account_args, location="form") + @use_args(account_args) def login_user(request, args): if request.method == "POST": login(args["username"], args["password"]) @@ -114,7 +114,7 @@ class BlogPostView(View): - @use_args(blog_args, location="query") + @use_args(blog_args) def get(self, request, args): blog_post = Post.objects.get(title__iexact=args["title"], author=args["author"]) return render_to_response("post_template.html", {"post": blog_post}) @@ -239,7 +239,7 @@ from webargs.pyramidparser import use_args - @use_args({"uid": fields.Str(), "per_page": fields.Int()}, location="query") + @use_args({"uid": fields.Str(), "per_page": fields.Int()}) def user_detail(request, args): uid = args["uid"] return Response( @@ -261,7 +261,7 @@ from webargs.pyramidparser import use_args - @use_args({"mymatch": fields.Int()}, location="matchdict") + @use_args({"mymatch": fields.Int()}, locations=("matchdict",)) def matched(request, args): return Response("The value for mymatch is {}".format(args["mymatch"])) @@ -310,14 +310,14 @@ def add_args(argmap, **kwargs): - def hook(req, resp, resource, params): + def hook(req, resp, params): parsed_args = parser.parse(argmap, req=req, **kwargs) req.context["args"] = parsed_args return hook - @falcon.before(add_args({"page": fields.Int()}, location="query")) + @falcon.before(add_args({"page": fields.Int(location="query")})) class AuthorResource: def on_get(self, req, resp): args = req.context["args"] @@ -414,7 +414,7 @@ from webargs.aiohttpparser import use_args - @parser.use_args({"slug": fields.Str()}, location="match_info") + @parser.use_args({"slug": fields.Str(location="match_info")}) def article_detail(request, args): return web.Response(body="Slug: {}".format(args["slug"]).encode("utf-8")) diff --git a/docs/index.rst b/docs/index.rst index 2be1d94..e152b9f 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -6,14 +6,6 @@ webargs is a Python library for parsing and validating HTTP request objects, with built-in support for popular web frameworks, including Flask, Django, Bottle, Tornado, Pyramid, webapp2, Falcon, and aiohttp. -Upgrading from an older version? --------------------------------- - -See the :doc:`Upgrading to Newer Releases ` page for notes on getting your code up-to-date with the latest version. - - -Usage and Simple Examples -------------------------- .. code-block:: python @@ -25,7 +17,7 @@ @app.route("/") - @use_args({"name": fields.Str(required=True)}, location="query") + @use_args({"name": fields.Str(required=True)}) def index(args): return "Hello " + args["name"] @@ -36,15 +28,13 @@ # curl http://localhost:5000/\?name\='World' # Hello World -By default Webargs will automatically parse JSON request bodies. But it also -has support for: +Webargs will automatically parse: **Query Parameters** :: - $ curl http://localhost:5000/\?name\='Freddie' - Hello Freddie - # pass location="query" to use_args + $ curl http://localhost:5000/\?name\='Freddie' + Hello Freddie **Form Data** :: @@ -52,15 +42,11 @@ $ curl -d 'name=Brian' http://localhost:5000/ Hello Brian - # pass location="form" to use_args - **JSON Data** :: $ curl -X POST -H "Content-Type: application/json" -d '{"name":"Roger"}' http://localhost:5000/ Hello Roger - - # pass location="json" (or omit location) to use_args and, optionally: @@ -117,6 +103,5 @@ license changelog - upgrading authors contributing diff --git a/docs/install.rst b/docs/install.rst index b96a683..62f36ea 100644 --- a/docs/install.rst +++ b/docs/install.rst @@ -1,7 +1,7 @@ Install ======= -**webargs** requires Python >= 3.5. It depends on `marshmallow `_ >= 2.7.0. +**webargs** requires Python >= 2.7 or >= 3.5. It depends on `marshmallow `_ >= 2.7.0. From the PyPI ------------- diff --git a/docs/quickstart.rst b/docs/quickstart.rst index 3d7ada0..b43371d 100644 --- a/docs/quickstart.rst +++ b/docs/quickstart.rst @@ -23,11 +23,17 @@ "nickname": fields.List(fields.Str()), # Delimited list, e.g. "/?languages=python,javascript" "languages": fields.DelimitedList(fields.Str()), + # When you know where an argument should be parsed from + "active": fields.Bool(location="query"), # When value is keyed on a variable-unsafe name # or you want to rename a key - "user_type": fields.Str(load_from="user-type"), + "content_type": fields.Str(load_from="Content-Type", location="headers"), # OR, on marshmallow 3 - # "user_type": fields.Str(data_key="user-type"), + # "content_type": fields.Str(data_key="Content-Type", location="headers"), + # File uploads + "profile_image": fields.Field( + location="files", validate=lambda f: f.mimetype in ["image/jpeg", "image/png"] + ), } .. note:: @@ -99,12 +105,12 @@ Request "Locations" ------------------- -By default, webargs will search for arguments from the request body as JSON. You can specify a different location from which to load data like so: +By default, webargs will search for arguments from the URL query string (e.g. ``"/?name=foo"``), form data, and JSON data (in that order). You can explicitly specify which locations to search, like so: .. code-block:: python @app.route("/register") - @use_args(user_args, location="form") + @use_args(user_args, locations=("json", "form")) def register(args): return "registration page" @@ -196,7 +202,7 @@ @parser.error_handler - def handle_error(error, req, schema, *, status_code, headers): + def handle_error(error, req, schema, status_code, headers): raise CustomError(error.messages) Parsing Lists in Query Strings @@ -237,7 +243,7 @@ .. note:: - Of the default supported locations in webargs, only the ``json`` request location supports nested datastructures. You can, however, :ref:`implement your own data loader ` to add nested field functionality to the other locations. + By default, webargs only parses nested fields using the ``json`` request location. You can, however, :ref:`implement your own parser ` to add nested field functionality to the other locations. Next Steps ---------- diff --git a/docs/upgrading.rst b/docs/upgrading.rst deleted file mode 100644 index 9879692..0000000 --- a/docs/upgrading.rst +++ /dev/null @@ -1,475 +0,0 @@ -Upgrading to Newer Releases -=========================== - -This section documents migration paths to new releases. - -Upgrading to 6.0 -++++++++++++++++ - -Multiple Locations Are No Longer Supported In A Single Call ------------------------------------------------------------ - -The default location is JSON/body. - -Under webargs 5.x, code often did not have to specify a location. - -Because webargs would parse data from multiple locations automatically, users -did not need to specify where a parameter, call it `q`, was passed. -`q` could be in a query parameter or in a JSON or form-post body. - -Now, webargs requires that users specify only one location for data loading per -`use_args` call, and `"json"` is the default. If `q` is intended to be a query -parameter, the developer must be explicit and rewrite like so: - -.. code-block:: python - - # webargs 5.x - @parser.use_args({"q": ma.fields.String()}) - def foo(args): - return some_function(user_query=args.get("q")) - - - # webargs 6.x - @parser.use_args({"q": ma.fields.String()}, location="query") - def foo(args): - return some_function(user_query=args.get("q")) - -This also means that another usage from 5.x is not supported. Code with -multiple locations in a single `use_args`, `use_kwargs`, or `parse` call -must be rewritten in multiple separate `use_args` or `use_kwargs` invocations, -like so: - -.. code-block:: python - - # webargs 5.x - @parser.use_kwargs( - { - "q1": ma.fields.Int(location="query"), - "q2": ma.fields.Int(location="query"), - "h1": ma.fields.Int(location="headers"), - }, - locations=("query", "headers"), - ) - def foo(q1, q2, h1): - ... - - - # webargs 6.x - @parser.use_kwargs({"q1": ma.fields.Int(), "q2": ma.fields.Int()}, location="query") - @parser.use_kwargs({"h1": ma.fields.Int()}, location="headers") - def foo(q1, q2, h1): - ... - - -Fields No Longer Support location=... -------------------------------------- - -Because a single `parser.use_args`, `parser.use_kwargs`, or `parser.parse` call -cannot specify multiple locations, it is not necessary for a field to be able -to specify its location. Rewrite code like so: - -.. code-block:: python - - # webargs 5.x - @parser.use_args({"q": ma.fields.String(location="query")}) - def foo(args): - return some_function(user_query=args.get("q")) - - - # webargs 6.x - @parser.use_args({"q": ma.fields.String()}, location="query") - def foo(args): - return some_function(user_query=args.get("q")) - -location_handler Has Been Replaced With location_loader -------------------------------------------------------- - -This is not just a name change. The expected signature of a `location_loader` -is slightly different from the signature for a `location_handler`. - -Where previously a `location_handler` code took the incoming request data and -details of a single field being loaded, a `location_loader` takes the request -and the schema as a pair. It does not return a specific field's data, but data -for the whole location. - -Rewrite code like this: - -.. code-block:: python - - # webargs 5.x - @parser.location_handler("data") - def load_data(request, name, field): - return request.data.get(name) - - - # webargs 6.x - @parser.location_loader("data") - def load_data(request, schema): - return request.data - -Data Is Not Filtered Before Being Passed To Schemas, And It May Be Proxified ----------------------------------------------------------------------------- - -In webargs 5.x, the deserialization schema was used to pull data out of the -request object. That data was compiled into a dictionary which was then passed -to the schema. - -One of the major changes in webargs 6.x allows the use of `unknown` parameter -on schemas. This lets a schema decide what to do with fields not specified in -the schema. In order to achieve this, webargs now passes the full data from -the specified location to the schema. - -Therefore, users should specify `unknown=marshmallow.EXCLUDE` on their schemas in -order to filter out unknown fields. Like so: - -.. code-block:: python - - # webargs 5.x - # this can assume that "q" is the only parameter passed, and all other - # parameters will be ignored - @parser.use_kwargs({"q": ma.fields.String()}, locations=("query",)) - def foo(q): - ... - - - # webargs 6.x, Solution 1: declare a schema with Meta.unknown set - class QuerySchema(ma.Schema): - q = ma.fields.String() - - class Meta: - unknown = ma.EXCLUDE - - - @parser.use_kwargs(QuerySchema, location="query") - def foo(q): - ... - - - # webargs 6.x, Solution 2: instantiate a schema with unknown set - class QuerySchema(ma.Schema): - q = ma.fields.String() - - - @parser.use_kwargs(QuerySchema(unknown=ma.EXCLUDE), location="query") - def foo(q): - ... - - -This also allows usage which passes the unknown parameters through, like so: - -.. code-block:: python - - # webargs 6.x only! cannot be done in 5.x - class QuerySchema(ma.Schema): - q = ma.fields.String() - - - # will pass *all* query params through as "kwargs" - @parser.use_kwargs(QuerySchema(unknown=ma.INCLUDE), location="query") - def foo(q, **kwargs): - ... - - -However, many types of request data are so-called "multidicts" -- dictionary-like -types which can return one or multiple values. To handle `marshmallow.fields.List` -and `webargs.fields.DelimitedList` fields correctly, passing list data, webargs -must combine schema information with the raw request data. This is done in the -:class:`MultiDictProxy ` type, which -will often be passed to schemas. - -This means that if a schema has a `pre_load` hook which interacts with the data, -it may need modifications. For example, a `flask` query string will be parsed -into an `ImmutableMultiDict` type, which will break pre-load hooks which modify -the data in-place. Such usages need rewrites like so: - -.. code-block:: python - - # webargs 5.x - # flask query params is just an example -- applies to several types - from webargs.flaskparser import use_kwargs - - - class QuerySchema(ma.Schema): - q = ma.fields.String() - - @ma.pre_load - def convert_nil_to_none(self, obj, **kwargs): - if obj.get("q") == "nil": - obj["q"] = None - return obj - - - @use_kwargs(QuerySchema, locations=("query",)) - def foo(q): - ... - - - # webargs 6.x - class QuerySchema(ma.Schema): - q = ma.fields.String() - - # unlike under 5.x, we cannot modify 'obj' in-place because writing - # to the MultiDictProxy will try to write to the underlying - # ImmutableMultiDict, which is not allowed - @ma.pre_load - def convert_nil_to_none(self, obj, **kwargs): - # creating a dict from a MultiDictProxy works well because it - # "unwraps" lists and delimited lists correctly - data = dict(obj) - if data.get("q") == "nil": - data["q"] = None - return data - - - @parser.use_kwargs(QuerySchema, location="query") - def foo(q): - ... - - -DelimitedList Now Only Takes A String Input -------------------------------------------- - -Combining `List` and string parsing functionality in a single type had some -messy corner cases. For the most part, this should not require rewrites. But -for APIs which need to allow both usages, rewrites are possible like so: - -.. code-block:: python - - # webargs 5.x - # this allows ...?x=1&x=2&x=3 - # as well as ...?x=1,2,3 - @use_kwargs({"x": webargs.fields.DelimitedList(ma.fields.Int)}, locations=("query",)) - def foo(x): - ... - - - # webargs 6.x - # this accepts x=1,2,3 but NOT x=1&x=2&x=3 - @use_kwargs({"x": webargs.fields.DelimitedList(ma.fields.Int)}, location="query") - def foo(x): - ... - - - # webargs 6.x - # this accepts x=1,2,3 ; x=1&x=2&x=3 ; x=1,2&x=3 - # to do this, it needs a post_load hook which will flatten out the list data - class UnpackingDelimitedListSchema(ma.Schema): - x = ma.fields.List(webargs.fields.DelimitedList(ma.fields.Int)) - - @ma.post_load - def flatten_lists(self, data, **kwargs): - new_x = [] - for x in data["x"]: - new_x.extend(x) - data["x"] = new_x - return data - - - @parser.use_kwargs(UnpackingDelimitedListSchema, location="query") - def foo(x): - ... - - -ValidationError Messages Are Namespaced Under The Location ----------------------------------------------------------- - -Code parsing ValidationError messages will notice a change in the messages -produced by webargs. -What would previously have come back with messages like `{"foo":["Not a valid integer."]}` -will now have messages nested one layer deeper, like -`{"json":{"foo":["Not a valid integer."]}}`. - -To rewrite code which was handling these errors, the handler will need to be -prepared to traverse messages by one additional level. For example: - -.. code-block:: python - - import logging - - log = logging.getLogger(__name__) - - - # webargs 5.x - # logs debug messages like - # bad value for 'foo': ["Not a valid integer."] - # bad value for 'bar': ["Not a valid boolean."] - def log_invalid_parameters(validation_error): - for field, messages in validation_error.messages.items(): - log.debug("bad value for '{}': {}".format(field, messages)) - - - # webargs 6.x - # logs debug messages like - # bad value for 'foo' [query]: ["Not a valid integer."] - # bad value for 'bar' [json]: ["Not a valid boolean."] - def log_invalid_parameters(validation_error): - for location, fielddata in validation_error.messages.items(): - for field, messages in fielddata.items(): - log.debug("bad value for '{}' [{}]: {}".format(field, location, messages)) - - -Some Functions Take Keyword-Only Arguments Now ----------------------------------------------- - -The signature of several methods has changed to have keyword-only arguments. -For the most part, this should not require any changes, but here's a list of -the changes. - -`parser.error_handler` methods: - -.. code-block:: python - - # webargs 5.x - def handle_error(error, req, schema, status_code, headers): - ... - - - # webargs 6.x - def handle_error(error, req, schema, *, status_code, headers): - ... - -`parser.__init__` methods: - -.. code-block:: python - - # webargs 5.x - def __init__(self, location=None, error_handler=None, schema_class=None): - ... - - - # webargs 6.x - def __init__(self, location=None, *, error_handler=None, schema_class=None): - ... - -`parser.parse`, `parser.use_args`, and `parser.use_kwargs` methods: - - -.. code-block:: python - - # webargs 5.x - def parse( - self, - argmap, - req=None, - location=None, - validate=None, - error_status_code=None, - error_headers=None, - ): - ... - - - # webargs 6.x - def parse( - self, - argmap, - req=None, - *, - location=None, - validate=None, - error_status_code=None, - error_headers=None - ): - ... - - - # webargs 5.x - def use_args( - self, - argmap, - req=None, - location=None, - as_kwargs=False, - validate=None, - error_status_code=None, - error_headers=None, - ): - ... - - - # webargs 6.x - def use_args( - self, - argmap, - req=None, - *, - location=None, - as_kwargs=False, - validate=None, - error_status_code=None, - error_headers=None - ): - ... - - - # use_kwargs is just an alias for use_args with as_kwargs=True - -and finally, the `dict2schema` function: - -.. code-block:: python - - # webargs 5.x - def dict2schema(dct, schema_class=ma.Schema): - ... - - - # webargs 6.x - def dict2schema(dct, *, schema_class=ma.Schema): - ... - - -PyramidParser Now Appends Arguments (Used To Prepend) ------------------------------------------------------ - -`PyramidParser.use_args` was not conformant with the other parsers in webargs. -While all other parsers added new arguments to the end of the argument list of -a decorated view function, the Pyramid implementation added them to the front -of the argument list. - -This has been corrected, but as a result pyramid views with `use_args` may need -to be rewritten. The `request` object is always passed first in both versions, -so the issue is only apparent with view functions taking other positional -arguments. - -For example, imagine code with a decorator for passing user information, -`pass_userinfo`, like so: - -.. code-block:: python - - # a decorator which gets information about the authenticated user - def pass_userinfo(f): - def decorator(request, *args, **kwargs): - return f(request, get_userinfo(), *args, **kwargs) - - return decorator - -You will see a behavioral change if `pass_userinfo` is called on a function -decorated with `use_args`. The difference between the two versions will be like -so: - -.. code-block:: python - - from webargs.pyramidparser import use_args - - # webargs 5.x - # pass_userinfo is called first, webargs sees positional arguments of - # (userinfo,) - # and changes it to - # (request, args, userinfo) - @pass_userinfo - @use_args({"q": ma.fields.String()}, locations=("query",)) - def viewfunc(request, args, userinfo): - q = args.get("q") - ... - - - # webargs 6.x - # pass_userinfo is called first, webargs sees positional arguments of - # (userinfo,) - # and changes it to - # (request, userinfo, args) - @pass_userinfo - @use_args({"q": ma.fields.String()}, location="query") - def viewfunc(request, userinfo, args): - q = args.get("q") - ... diff --git a/examples/falcon_example.py b/examples/falcon_example.py index 1b5b973..c5fd596 100644 --- a/examples/falcon_example.py +++ b/examples/falcon_example.py @@ -27,7 +27,7 @@ ### Middleware and hooks ### -class JSONTranslator: +class JSONTranslator(object): def process_response(self, req, resp, resource): if "result" not in req.context: return @@ -44,7 +44,7 @@ ### Resources ### -class HelloResource: +class HelloResource(object): """A welcome page.""" hello_args = {"name": fields.Str(missing="Friend", location="query")} @@ -54,7 +54,7 @@ req.context["result"] = {"message": "Welcome, {}!".format(args["name"])} -class AdderResource: +class AdderResource(object): """An addition endpoint.""" adder_args = {"x": fields.Float(required=True), "y": fields.Float(required=True)} @@ -64,7 +64,7 @@ req.context["result"] = {"result": x + y} -class DateAddResource: +class DateAddResource(object): """A datetime adder endpoint.""" dateadd_args = { diff --git a/examples/flaskrestful_example.py b/examples/flaskrestful_example.py index 8e83d80..0dfaa77 100644 --- a/examples/flaskrestful_example.py +++ b/examples/flaskrestful_example.py @@ -1,3 +1,4 @@ +# -*- coding: utf-8 -*- """A simple number and datetime addition JSON API. Run the app: @@ -69,7 +70,7 @@ # This error handler is necessary for usage with Flask-RESTful @parser.error_handler -def handle_request_parsing_error(err, req, schema, *, error_status_code, error_headers): +def handle_request_parsing_error(err, req, schema, error_status_code, error_headers): """webargs error handler that uses Flask-RESTful's abort function to return a JSON error response to the client. """ diff --git a/examples/requirements.txt b/examples/requirements.txt index a98db13..62d4d40 100644 --- a/examples/requirements.txt +++ b/examples/requirements.txt @@ -1,4 +1,4 @@ -python-dateutil==2.8.1 +python-dateutil==2.8.0 Flask bottle tornado diff --git a/examples/webapp2_example.py b/examples/webapp2_example.py index 2111ed8..9d0869f 100755 --- a/examples/webapp2_example.py +++ b/examples/webapp2_example.py @@ -1,4 +1,5 @@ #!/usr/bin/env python +# -*- coding: utf-8 -*- """A Hello, World! example using Webapp2 in a Google App Engine environment Run the app: diff --git a/setup.cfg b/setup.cfg index 7c247eb..6f10b32 100644 --- a/setup.cfg +++ b/setup.cfg @@ -2,7 +2,10 @@ license_files = LICENSE [bdist_wheel] -universal = 1 +# We build separate wheels for +# Python 2 and 3 because of the conditional +# dependency on simplejson +universal = 0 [flake8] ignore = E203, E266, E501, W503 diff --git a/setup.py b/setup.py index 3dc116a..993ada1 100644 --- a/setup.py +++ b/setup.py @@ -1,5 +1,11 @@ +# -*- coding: utf-8 -*- +import sys import re from setuptools import setup, find_packages + +INSTALL_REQUIRES = ["marshmallow>=2.15.2"] +if sys.version_info[0] < 3: + INSTALL_REQUIRES.append("simplejson>=2.1.0") FRAMEWORKS = [ "Flask>=0.12.2", @@ -8,26 +14,26 @@ "tornado>=4.5.2", "pyramid>=1.9.1", "webapp2>=3.0.0b1", - "falcon>=2.0.0", - "aiohttp>=3.0.0", + "falcon>=1.4.0,<2.0", + 'aiohttp>=3.0.0; python_version >= "3.5"', ] EXTRAS_REQUIRE = { "frameworks": FRAMEWORKS, "tests": [ "pytest", - 'mock; python_version == "3.5"', - "webtest==2.0.35", - "webtest-aiohttp==2.0.0", - "pytest-aiohttp>=0.3.0", + "mock", + "webtest==2.0.33", + 'webtest-aiohttp==2.0.0; python_version >= "3.5"', + 'pytest-aiohttp>=0.3.0; python_version >= "3.5"', ] + FRAMEWORKS, "lint": [ - "mypy==0.770", - "flake8==3.7.9", - "flake8-bugbear==20.1.4", - "pre-commit>=1.20,<3.0", + 'mypy==0.730; python_version >= "3.5"', + "flake8==3.7.8", + 'flake8-bugbear==19.8.0; python_version >= "3.5"', + "pre-commit~=1.17", ], - "docs": ["Sphinx==3.0.3", "sphinx-issues==1.2.0", "sphinx-typlog-theme==0.8.0"] + "docs": ["Sphinx==2.2.0", "sphinx-issues==1.2.0", "sphinx-typlog-theme==0.7.3"] + FRAMEWORKS, } EXTRAS_REQUIRE["dev"] = EXTRAS_REQUIRE["tests"] + EXTRAS_REQUIRE["lint"] + ["tox"] @@ -70,7 +76,7 @@ url="https://github.com/marshmallow-code/webargs", packages=find_packages("src"), package_dir={"": "src"}, - install_requires=["marshmallow>=2.15.2"], + install_requires=INSTALL_REQUIRES, extras_require=EXTRAS_REQUIRE, license="MIT", zip_safe=False, @@ -91,18 +97,17 @@ "api", "marshmallow", ), - python_requires=">=3.5", classifiers=[ "Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Natural Language :: English", + "Programming Language :: Python :: 2", + "Programming Language :: Python :: 2.7", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.5", "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", - "Programming Language :: Python :: 3.8", - "Programming Language :: Python :: 3 :: Only", "Topic :: Internet :: WWW/HTTP :: Dynamic Content", "Topic :: Internet :: WWW/HTTP :: WSGI :: Application", ], diff --git a/src/webargs/__init__.py b/src/webargs/__init__.py index 2e60fbe..b91dbc5 100755 --- a/src/webargs/__init__.py +++ b/src/webargs/__init__.py @@ -1,3 +1,4 @@ +# -*- coding: utf-8 -*- from distutils.version import LooseVersion from marshmallow.utils import missing @@ -8,6 +9,6 @@ from webargs.dict2schema import dict2schema from webargs import fields -__version__ = "6.1.0" +__version__ = "5.5.3" __version_info__ = tuple(LooseVersion(__version__).version) __all__ = ("dict2schema", "ValidationError", "fields", "missing", "validate") diff --git a/src/webargs/aiohttpparser.py b/src/webargs/aiohttpparser.py index 2242c02..9e76e05 100644 --- a/src/webargs/aiohttpparser.py +++ b/src/webargs/aiohttpparser.py @@ -28,11 +28,11 @@ from aiohttp.web import Request from aiohttp import web_exceptions from marshmallow import Schema, ValidationError +from marshmallow.fields import Field from webargs import core from webargs.core import json from webargs.asyncparser import AsyncParser -from webargs.multidictproxy import MultiDictProxy def is_json_request(req: Request) -> bool: @@ -73,58 +73,58 @@ """aiohttp request argument parser.""" __location_map__ = dict( - match_info="load_match_info", - path="load_match_info", - **core.Parser.__location_map__, + match_info="parse_match_info", + path="parse_match_info", + **core.Parser.__location_map__ ) - def load_querystring(self, req: Request, schema: Schema) -> MultiDictProxy: - """Return query params from the request as a MultiDictProxy.""" - return MultiDictProxy(req.query, schema) + def parse_querystring(self, req: Request, name: str, field: Field) -> typing.Any: + """Pull a querystring value from the request.""" + return core.get_value(req.query, name, field) - async def load_form(self, req: Request, schema: Schema) -> MultiDictProxy: - """Return form values from the request as a MultiDictProxy.""" - post_data = await req.post() - return MultiDictProxy(post_data, schema) + async def parse_form(self, req: Request, name: str, field: Field) -> typing.Any: + """Pull a form value from the request.""" + post_data = self._cache.get("post") + if post_data is None: + self._cache["post"] = await req.post() + return core.get_value(self._cache["post"], name, field) - async def load_json_or_form( - self, req: Request, schema: Schema - ) -> typing.Union[typing.Dict, MultiDictProxy]: - data = await self.load_json(req, schema) - if data is not core.missing: - return data - return await self.load_form(req, schema) + async def parse_json(self, req: Request, name: str, field: Field) -> typing.Any: + """Pull a json value from the request.""" + json_data = self._cache.get("json") + if json_data is None: + if not (req.body_exists and is_json_request(req)): + return core.missing + try: + json_data = await req.json(loads=json.loads) + except json.JSONDecodeError as e: + if e.doc == "": + return core.missing + else: + return self.handle_invalid_json_error(e, req) + except UnicodeDecodeError as e: + return self.handle_invalid_json_error(e, req) - async def load_json(self, req: Request, schema: Schema) -> typing.Dict: - """Return a parsed json payload from the request.""" - if not (req.body_exists and is_json_request(req)): - return core.missing - try: - return await req.json(loads=json.loads) - except json.JSONDecodeError as exc: - if exc.doc == "": - return core.missing - return self._handle_invalid_json_error(exc, req) - except UnicodeDecodeError as exc: - return self._handle_invalid_json_error(exc, req) + self._cache["json"] = json_data + return core.get_value(json_data, name, field, allow_many_nested=True) - def load_headers(self, req: Request, schema: Schema) -> MultiDictProxy: - """Return headers from the request as a MultiDictProxy.""" - return MultiDictProxy(req.headers, schema) + def parse_headers(self, req: Request, name: str, field: Field) -> typing.Any: + """Pull a value from the header data.""" + return core.get_value(req.headers, name, field) - def load_cookies(self, req: Request, schema: Schema) -> MultiDictProxy: - """Return cookies from the request as a MultiDictProxy.""" - return MultiDictProxy(req.cookies, schema) + def parse_cookies(self, req: Request, name: str, field: Field) -> typing.Any: + """Pull a value from the cookiejar.""" + return core.get_value(req.cookies, name, field) - def load_files(self, req: Request, schema: Schema) -> "typing.NoReturn": + def parse_files(self, req: Request, name: str, field: Field) -> None: raise NotImplementedError( - "load_files is not implemented. You may be able to use load_form for " + "parse_files is not implemented. You may be able to use parse_form for " "parsing upload data." ) - def load_match_info(self, req: Request, schema: Schema) -> typing.Mapping: - """Load the request's ``match_info``.""" - return req.match_info + def parse_match_info(self, req: Request, name: str, field: Field) -> typing.Any: + """Pull a value from the request's ``match_info``.""" + return core.get_value(req.match_info, name, field) def get_request_from_view_args( self, view: typing.Callable, args: typing.Iterable, kwargs: typing.Mapping @@ -137,11 +137,10 @@ if isinstance(arg, web.Request): req = arg break - if isinstance(arg, web.View): + elif isinstance(arg, web.View): req = arg.request break - if not isinstance(req, web.Request): - raise ValueError("Request argument not found for handler") + assert isinstance(req, web.Request), "Request argument not found for handler" return req def handle_error( @@ -149,9 +148,8 @@ error: ValidationError, req: Request, schema: Schema, - *, - error_status_code: typing.Union[int, None], - error_headers: typing.Union[typing.Mapping[str, str], None] + error_status_code: typing.Union[int, None] = None, + error_headers: typing.Union[typing.Mapping[str, str], None] = None, ) -> "typing.NoReturn": """Handle ValidationErrors and return a JSON response of error messages to the client. @@ -160,7 +158,7 @@ error_status_code or self.DEFAULT_VALIDATION_STATUS ) if not error_class: - raise LookupError("No exception for {}".format(error_status_code)) + raise LookupError("No exception for {0}".format(error_status_code)) headers = error_headers raise error_class( body=json.dumps(error.messages).encode("utf-8"), @@ -168,7 +166,7 @@ content_type="application/json", ) - def _handle_invalid_json_error( + def handle_invalid_json_error( self, error: typing.Union[json.JSONDecodeError, UnicodeDecodeError], req: Request, diff --git a/src/webargs/asyncparser.py b/src/webargs/asyncparser.py index 1ba77c7..8236901 100644 --- a/src/webargs/asyncparser.py +++ b/src/webargs/asyncparser.py @@ -8,6 +8,7 @@ from marshmallow import Schema, ValidationError from marshmallow.fields import Field import marshmallow as ma +from marshmallow.utils import missing from webargs import core @@ -20,64 +21,79 @@ """Asynchronous variant of `webargs.core.Parser`, where parsing methods may be either coroutines or regular methods. """ + + async def _parse_request( + self, schema: Schema, req: Request, locations: typing.Iterable + ) -> typing.Union[dict, list]: + if schema.many: + assert ( + "json" in locations + ), "schema.many=True is only supported for JSON location" + # The ad hoc Nested field is more like a workaround or a helper, + # and it servers its purpose fine. However, if somebody has a desire + # to re-design the support of bulk-type arguments, go ahead. + parsed = await self.parse_arg( + name="json", + field=ma.fields.Nested(schema, many=True), + req=req, + locations=locations, + ) + if parsed is missing: + parsed = [] + else: + argdict = schema.fields + parsed = {} + for argname, field_obj in argdict.items(): + if core.MARSHMALLOW_VERSION_INFO[0] < 3: + parsed_value = await self.parse_arg( + argname, field_obj, req, locations + ) + # If load_from is specified on the field, try to parse from that key + if parsed_value is missing and field_obj.load_from: + parsed_value = await self.parse_arg( + field_obj.load_from, field_obj, req, locations + ) + argname = field_obj.load_from + else: + argname = field_obj.data_key or argname + parsed_value = await self.parse_arg( + argname, field_obj, req, locations + ) + if parsed_value is not missing: + parsed[argname] = parsed_value + return parsed # TODO: Lots of duplication from core.Parser here. Rethink. async def parse( self, argmap: ArgMap, req: Request = None, - *, - location: str = None, + locations: typing.Iterable = None, validate: Validate = None, error_status_code: typing.Union[int, None] = None, - error_headers: typing.Union[typing.Mapping[str, str], None] = None + error_headers: typing.Union[typing.Mapping[str, str], None] = None, ) -> typing.Union[typing.Mapping, None]: """Coroutine variant of `webargs.core.Parser`. Receives the same arguments as `webargs.core.Parser.parse`. """ + self.clear_cache() # in case someone used `parse_*()` req = req if req is not None else self.get_default_request() - location = location or self.location - if req is None: - raise ValueError("Must pass req object") + assert req is not None, "Must pass req object" data = None validators = core._ensure_list_of_callables(validate) schema = self._get_schema(argmap, req) try: - location_data = await self._load_location_data( - schema=schema, req=req, location=location + parsed = await self._parse_request( + schema=schema, req=req, locations=locations or self.locations ) - result = schema.load(location_data) + result = schema.load(parsed) data = result.data if core.MARSHMALLOW_VERSION_INFO[0] < 3 else result self._validate_arguments(data, validators) except ma.exceptions.ValidationError as error: await self._on_validation_error( - error, - req, - schema, - location, - error_status_code=error_status_code, - error_headers=error_headers, + error, req, schema, error_status_code, error_headers ) - return data - - async def _load_location_data(self, schema, req, location): - """Return a dictionary-like object for the location on the given request. - - Needs to have the schema in hand in order to correctly handle loading - lists from multidict objects and `many=True` schemas. - """ - loader_func = self._get_loader(location) - if asyncio.iscoroutinefunction(loader_func): - data = await loader_func(req, schema) - else: - data = loader_func(req, schema) - - # when the desired location is empty (no data), provide an empty - # dict as the default so that optional arguments in a location - # (e.g. optional JSON body) work smoothly - if data is core.missing: - data = {} return data async def _on_validation_error( @@ -85,47 +101,32 @@ error: ValidationError, req: Request, schema: Schema, - location: str, - *, error_status_code: typing.Union[int, None], - error_headers: typing.Union[typing.Mapping[str, str], None] + error_headers: typing.Union[typing.Mapping[str, str], None] = None, ) -> None: - # rewrite messages to be namespaced under the location which created - # them - # e.g. {"json":{"foo":["Not a valid integer."]}} - # instead of - # {"foo":["Not a valid integer."]} - error.messages = {location: error.messages} error_handler = self.error_callback or self.handle_error - await error_handler( - error, - req, - schema, - error_status_code=error_status_code, - error_headers=error_headers, - ) + await error_handler(error, req, schema, error_status_code, error_headers) def use_args( self, argmap: ArgMap, req: typing.Optional[Request] = None, - *, - location: str = None, + locations: typing.Iterable = None, as_kwargs: bool = False, validate: Validate = None, error_status_code: typing.Optional[int] = None, - error_headers: typing.Union[typing.Mapping[str, str], None] = None + error_headers: typing.Union[typing.Mapping[str, str], None] = None, ) -> typing.Callable[..., typing.Callable]: """Decorator that injects parsed arguments into a view function or method. Receives the same arguments as `webargs.core.Parser.use_args`. """ - location = location or self.location + locations = locations or self.locations request_obj = req # Optimization: If argmap is passed as a dictionary, we only need # to generate a Schema once if isinstance(argmap, Mapping): - argmap = core.dict2schema(argmap, schema_class=self.schema_class)() + argmap = core.dict2schema(argmap, self.schema_class)() def decorator(func: typing.Callable) -> typing.Callable: req_ = request_obj @@ -142,15 +143,18 @@ parsed_args = await self.parse( argmap, req=req_obj, - location=location, + locations=locations, validate=validate, error_status_code=error_status_code, error_headers=error_headers, ) - args, kwargs = self._update_args_kwargs( - args, kwargs, parsed_args, as_kwargs - ) - return await func(*args, **kwargs) + if as_kwargs: + kwargs.update(parsed_args or {}) + return await func(*args, **kwargs) + else: + # Add parsed_args after other positional arguments + new_args = args + (parsed_args,) + return await func(*new_args, **kwargs) else: @@ -164,16 +168,53 @@ parsed_args = yield from self.parse( # type: ignore argmap, req=req_obj, - location=location, + locations=locations, validate=validate, error_status_code=error_status_code, error_headers=error_headers, ) - args, kwargs = self._update_args_kwargs( - args, kwargs, parsed_args, as_kwargs - ) - return func(*args, **kwargs) + if as_kwargs: + kwargs.update(parsed_args) + return func(*args, **kwargs) # noqa: B901 + else: + # Add parsed_args after other positional arguments + new_args = args + (parsed_args,) + return func(*new_args, **kwargs) return wrapper return decorator + + def use_kwargs(self, *args, **kwargs) -> typing.Callable: + """Decorator that injects parsed arguments into a view function or method. + + Receives the same arguments as `webargs.core.Parser.use_kwargs`. + + """ + return super().use_kwargs(*args, **kwargs) + + async def parse_arg( + self, name: str, field: Field, req: Request, locations: typing.Iterable = None + ) -> typing.Any: + location = field.metadata.get("location") + if location: + locations_to_check = self._validated_locations([location]) + else: + locations_to_check = self._validated_locations(locations or self.locations) + + for location in locations_to_check: + value = await self._get_value(name, field, req=req, location=location) + # Found the value; validate and return it + if value is not core.missing: + return value + return core.missing + + async def _get_value( + self, name: str, argobj: Field, req: Request, location: str + ) -> typing.Any: + function = self._get_handler(location) + if asyncio.iscoroutinefunction(function): + value = await function(req, name, argobj) + else: + value = function(req, name, argobj) + return value diff --git a/src/webargs/bottleparser.py b/src/webargs/bottleparser.py index 3cfd299..310aa38 100644 --- a/src/webargs/bottleparser.py +++ b/src/webargs/bottleparser.py @@ -1,3 +1,4 @@ +# -*- coding: utf-8 -*- """Bottle request argument parsing module. Example: :: @@ -19,60 +20,58 @@ import bottle from webargs import core -from webargs.multidictproxy import MultiDictProxy +from webargs.core import json class BottleParser(core.Parser): """Bottle.py request argument parser.""" - def _handle_invalid_json_error(self, error, req, *args, **kwargs): - raise bottle.HTTPError( - status=400, body={"json": ["Invalid JSON body."]}, exception=error - ) + def parse_querystring(self, req, name, field): + """Pull a querystring value from the request.""" + return core.get_value(req.query, name, field) - def _raw_load_json(self, req): - """Read a json payload from the request.""" - try: - data = req.json - except AttributeError: - return core.missing - - # unfortunately, bottle does not distinguish between an emtpy body, "", - # and a body containing the valid JSON value null, "null" - # so these can't be properly disambiguated - # as our best-effort solution, treat None as missing and ignore the - # (admittedly unusual) "null" case - # see: https://github.com/bottlepy/bottle/issues/1160 - if data is None: - return core.missing - return data - - def load_querystring(self, req, schema): - """Return query params from the request as a MultiDictProxy.""" - return MultiDictProxy(req.query, schema) - - def load_form(self, req, schema): - """Return form values from the request as a MultiDictProxy.""" + def parse_form(self, req, name, field): + """Pull a form value from the request.""" # For consistency with other parsers' behavior, don't attempt to # parse if content-type is mismatched. # TODO: Make this check more specific if core.is_json(req.content_type): return core.missing - return MultiDictProxy(req.forms, schema) + return core.get_value(req.forms, name, field) - def load_headers(self, req, schema): - """Return headers from the request as a MultiDictProxy.""" - return MultiDictProxy(req.headers, schema) + def parse_json(self, req, name, field): + """Pull a json value from the request.""" + json_data = self._cache.get("json") + if json_data is None: + try: + self._cache["json"] = json_data = req.json + except AttributeError: + return core.missing + except json.JSONDecodeError as e: + if e.doc == "": + return core.missing + else: + return self.handle_invalid_json_error(e, req) + except UnicodeDecodeError as e: + return self.handle_invalid_json_error(e, req) - def load_cookies(self, req, schema): - """Return cookies from the request.""" - return req.cookies + if json_data is None: + return core.missing + return core.get_value(json_data, name, field, allow_many_nested=True) - def load_files(self, req, schema): - """Return files from the request as a MultiDictProxy.""" - return MultiDictProxy(req.files, schema) + def parse_headers(self, req, name, field): + """Pull a value from the header data.""" + return core.get_value(req.headers, name, field) - def handle_error(self, error, req, schema, *, error_status_code, error_headers): + def parse_cookies(self, req, name, field): + """Pull a value from the cookiejar.""" + return req.get_cookie(name) + + def parse_files(self, req, name, field): + """Pull a file from the request.""" + return core.get_value(req.files, name, field) + + def handle_error(self, error, req, schema, error_status_code, error_headers): """Handles errors during parsing. Aborts the current request with a 400 error. """ @@ -84,6 +83,11 @@ exception=error, ) + def handle_invalid_json_error(self, error, req, *args, **kwargs): + raise bottle.HTTPError( + status=400, body={"json": ["Invalid JSON body."]}, exception=error + ) + def get_default_request(self): """Override to use bottle's thread-local request object by default.""" return bottle.request diff --git a/src/webargs/compat.py b/src/webargs/compat.py index 2bbe8d5..5676a1e 100644 --- a/src/webargs/compat.py +++ b/src/webargs/compat.py @@ -1,6 +1,22 @@ +# -*- coding: utf-8 -*- # flake8: noqa +import sys from distutils.version import LooseVersion import marshmallow as ma MARSHMALLOW_VERSION_INFO = tuple(LooseVersion(ma.__version__).version) # type: tuple +PY2 = int(sys.version_info[0]) == 2 + +if PY2: + from collections import Mapping + + basestring = basestring + text_type = unicode + iteritems = lambda d: d.iteritems() +else: + from collections.abc import Mapping + + basestring = (str, bytes) + text_type = str + iteritems = lambda d: d.items() diff --git a/src/webargs/core.py b/src/webargs/core.py index 4888fe0..fe2f39b 100644 --- a/src/webargs/core.py +++ b/src/webargs/core.py @@ -1,16 +1,22 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + import functools import inspect -import typing import logging import warnings -from collections.abc import Mapping -import json +from copy import copy + +try: + import simplejson as json +except ImportError: + import json # type: ignore import marshmallow as ma from marshmallow import ValidationError -from marshmallow.utils import missing - -from webargs.compat import MARSHMALLOW_VERSION_INFO +from marshmallow.utils import missing, is_collection + +from webargs.compat import Mapping, iteritems, MARSHMALLOW_VERSION_INFO from webargs.dict2schema import dict2schema from webargs.fields import DelimitedList @@ -22,6 +28,7 @@ "dict2schema", "is_multiple", "Parser", + "get_value", "missing", "parse_json", ] @@ -35,8 +42,9 @@ callable, a ValueError is raised. """ if obj and not callable(obj): - raise ValueError("{!r} is not callable.".format(obj)) - return obj + raise ValueError("{0!r} is not callable.".format(obj)) + else: + return obj def is_multiple(field): @@ -66,17 +74,53 @@ return False -def parse_json(string, *, encoding="utf-8"): - if isinstance(string, bytes): +def get_value(data, name, field, allow_many_nested=False): + """Get a value from a dictionary. Handles ``MultiDict`` types when + ``field`` handles repeated/multi-value arguments. + If the value is not found, return `missing`. + + :param object data: Mapping (e.g. `dict`) or list-like instance to + pull the value from. + :param str name: Name of the key. + :param bool allow_many_nested: Whether to allow a list of nested objects + (it is valid only for JSON format, so it is set to True in ``parse_json`` + methods). + """ + missing_value = missing + if allow_many_nested and isinstance(field, ma.fields.Nested) and field.many: + if is_collection(data): + return data + + if not hasattr(data, "get"): + return missing_value + + multiple = is_multiple(field) + val = data.get(name, missing_value) + if multiple and val is not missing: + if hasattr(data, "getlist"): + return data.getlist(name) + elif hasattr(data, "getall"): + return data.getall(name) + elif isinstance(val, (list, tuple)): + return val + if val is None: + return None + else: + return [val] + return val + + +def parse_json(s, encoding="utf-8"): + if isinstance(s, bytes): try: - string = string.decode(encoding) - except UnicodeDecodeError as exc: + s = s.decode(encoding) + except UnicodeDecodeError as e: raise json.JSONDecodeError( - "Bytes decoding error : {}".format(exc.reason), - doc=str(exc.object), - pos=exc.start, + "Bytes decoding error : {}".format(e.reason), + doc=str(e.object), + pos=e.start, ) - return json.loads(string) + return json.loads(s) def _ensure_list_of_callables(obj): @@ -86,26 +130,27 @@ elif callable(obj): validators = [obj] else: - raise ValueError("{!r} is not a callable or list of callables.".format(obj)) + raise ValueError( + "{0!r} is not a callable or list of callables.".format(obj) + ) else: validators = [] return validators -class Parser: +class Parser(object): """Base parser class that provides high-level implementation for parsing a request. - Descendant classes must provide lower-level implementations for reading - data from different locations, e.g. ``load_json``, ``load_querystring``, - etc. - - :param str location: Default location to use for data + Descendant classes must provide lower-level implementations for parsing + different locations, e.g. ``parse_json``, ``parse_querystring``, etc. + + :param tuple locations: Default locations to parse. :param callable error_handler: Custom error handler function. """ - #: Default location to check for data - DEFAULT_LOCATION = "json" + #: Default locations to check for data + DEFAULT_LOCATIONS = ("querystring", "form", "json") #: The marshmallow Schema class to use when creating new schemas DEFAULT_SCHEMA_CLASS = ma.Schema #: Default status code to return for validation errors @@ -115,31 +160,38 @@ #: Maps location => method name __location_map__ = { - "json": "load_json", - "querystring": "load_querystring", - "query": "load_querystring", - "form": "load_form", - "headers": "load_headers", - "cookies": "load_cookies", - "files": "load_files", - "json_or_form": "load_json_or_form", + "json": "parse_json", + "querystring": "parse_querystring", + "query": "parse_querystring", + "form": "parse_form", + "headers": "parse_headers", + "cookies": "parse_cookies", + "files": "parse_files", } - def __init__(self, location=None, *, error_handler=None, schema_class=None): - self.location = location or self.DEFAULT_LOCATION + def __init__(self, locations=None, error_handler=None, schema_class=None): + self.locations = locations or self.DEFAULT_LOCATIONS self.error_callback = _callable_or_raise(error_handler) self.schema_class = schema_class or self.DEFAULT_SCHEMA_CLASS - - def _get_loader(self, location): - """Get the loader function for the given location. - - :raises: ValueError if a given location is invalid. - """ + #: A short-lived cache to store results from processing request bodies. + self._cache = {} + + def _validated_locations(self, locations): + """Ensure that the given locations argument is valid. + + :raises: ValueError if a given locations includes an invalid location. + """ + # The set difference between the given locations and the available locations + # will be the set of invalid locations valid_locations = set(self.__location_map__.keys()) - if location not in valid_locations: - msg = "Invalid location argument: {}".format(location) + given = set(locations) + invalid_locations = given - valid_locations + if len(invalid_locations): + msg = "Invalid locations arguments: {0}".format(list(invalid_locations)) raise ValueError(msg) - + return locations + + def _get_handler(self, location): # Parsing function to call # May be a method name (str) or a function func = self.__location_map__.get(location) @@ -149,41 +201,82 @@ else: function = getattr(self, func) else: - raise ValueError('Invalid location: "{}"'.format(location)) + raise ValueError('Invalid location: "{0}"'.format(location)) return function - def _load_location_data(self, *, schema, req, location): - """Return a dictionary-like object for the location on the given request. - - Needs to have the schema in hand in order to correctly handle loading - lists from multidict objects and `many=True` schemas. - """ - loader_func = self._get_loader(location) - data = loader_func(req, schema) - # when the desired location is empty (no data), provide an empty - # dict as the default so that optional arguments in a location - # (e.g. optional JSON body) work smoothly - if data is missing: - data = {} - return data + def _get_value(self, name, argobj, req, location): + function = self._get_handler(location) + return function(req, name, argobj) + + def parse_arg(self, name, field, req, locations=None): + """Parse a single argument from a request. + + .. note:: + This method does not perform validation on the argument. + + :param str name: The name of the value. + :param marshmallow.fields.Field field: The marshmallow `Field` for the request + parameter. + :param req: The request object to parse. + :param tuple locations: The locations ('json', 'querystring', etc.) where + to search for the value. + :return: The unvalidated argument value or `missing` if the value cannot + be found on the request. + """ + location = field.metadata.get("location") + if location: + locations_to_check = self._validated_locations([location]) + else: + locations_to_check = self._validated_locations(locations or self.locations) + + for location in locations_to_check: + value = self._get_value(name, field, req=req, location=location) + # Found the value; validate and return it + if value is not missing: + return value + return missing + + def _parse_request(self, schema, req, locations): + """Return a parsed arguments dictionary for the current request.""" + if schema.many: + assert ( + "json" in locations + ), "schema.many=True is only supported for JSON location" + # The ad hoc Nested field is more like a workaround or a helper, + # and it servers its purpose fine. However, if somebody has a desire + # to re-design the support of bulk-type arguments, go ahead. + parsed = self.parse_arg( + name="json", + field=ma.fields.Nested(schema, many=True), + req=req, + locations=locations, + ) + if parsed is missing: + parsed = [] + else: + argdict = schema.fields + parsed = {} + for argname, field_obj in iteritems(argdict): + if MARSHMALLOW_VERSION_INFO[0] < 3: + parsed_value = self.parse_arg(argname, field_obj, req, locations) + # If load_from is specified on the field, try to parse from that key + if parsed_value is missing and field_obj.load_from: + parsed_value = self.parse_arg( + field_obj.load_from, field_obj, req, locations + ) + argname = field_obj.load_from + else: + argname = field_obj.data_key or argname + parsed_value = self.parse_arg(argname, field_obj, req, locations) + if parsed_value is not missing: + parsed[argname] = parsed_value + return parsed def _on_validation_error( - self, error, req, schema, location, *, error_status_code, error_headers + self, error, req, schema, error_status_code, error_headers ): - # rewrite messages to be namespaced under the location which created - # them - # e.g. {"json":{"foo":["Not a valid integer."]}} - # instead of - # {"foo":["Not a valid integer."]} - error.messages = {location: error.messages} error_handler = self.error_callback or self.handle_error - error_handler( - error, - req, - schema, - error_status_code=error_status_code, - error_headers=error_headers, - ) + error_handler(error, req, schema, error_status_code, error_headers) def _validate_arguments(self, data, validators): for validator in validators: @@ -207,7 +300,7 @@ elif callable(argmap): schema = argmap(req) else: - schema = dict2schema(argmap, schema_class=self.schema_class)() + schema = dict2schema(argmap, self.schema_class)() if MARSHMALLOW_VERSION_INFO[0] < 3 and not schema.strict: warnings.warn( "It is highly recommended that you set strict=True on your schema " @@ -216,15 +309,19 @@ ) return schema + def _clone(self): + clone = copy(self) + clone.clear_cache() + return clone + def parse( self, argmap, req=None, - *, - location=None, + locations=None, validate=None, error_status_code=None, - error_headers=None + error_headers=None, ): """Main request parsing method. @@ -232,10 +329,9 @@ of argname -> `marshmallow.fields.Field` pairs, or a callable which accepts a request and returns a `marshmallow.Schema`. :param req: The request object to parse. - :param str location: Where on the request to load values. - Can be any of the values in :py:attr:`~__location_map__`. By - default, that means one of ``('json', 'query', 'querystring', - 'form', 'headers', 'cookies', 'files', 'json_or_form')``. + :param tuple locations: Where on the request to search for values. + Can include one or more of ``('json', 'querystring', 'form', + 'headers', 'cookies', 'files')``. :param callable validate: Validation function or list of validation functions that receives the dictionary of parsed arguments. Validator either returns a boolean or raises a :exc:`ValidationError`. @@ -246,30 +342,36 @@ :return: A dictionary of parsed arguments """ + self.clear_cache() # in case someone used `parse_*()` req = req if req is not None else self.get_default_request() - location = location or self.location - if req is None: - raise ValueError("Must pass req object") + assert req is not None, "Must pass req object" data = None validators = _ensure_list_of_callables(validate) + parser = self._clone() schema = self._get_schema(argmap, req) try: - location_data = self._load_location_data( - schema=schema, req=req, location=location + parsed = parser._parse_request( + schema=schema, req=req, locations=locations or self.locations ) - result = schema.load(location_data) + result = schema.load(parsed) data = result.data if MARSHMALLOW_VERSION_INFO[0] < 3 else result - self._validate_arguments(data, validators) + parser._validate_arguments(data, validators) except ma.exceptions.ValidationError as error: - self._on_validation_error( - error, - req, - schema, - location, - error_status_code=error_status_code, - error_headers=error_headers, + parser._on_validation_error( + error, req, schema, error_status_code, error_headers ) return data + + def clear_cache(self): + """Invalidate the parser's cache. + + This is usually a no-op now since the Parser clone used for parsing a + request is discarded afterwards. It can still be used when manually + calling ``parse_*`` methods which would populate the cache on the main + Parser instance. + """ + self._cache = {} + return None def get_default_request(self): """Optional override. Provides a hook for frameworks that use thread-local @@ -291,40 +393,29 @@ """ return None - @staticmethod - def _update_args_kwargs(args, kwargs, parsed_args, as_kwargs): - """Update args or kwargs with parsed_args depending on as_kwargs""" - if as_kwargs: - kwargs.update(parsed_args) - else: - # Add parsed_args after other positional arguments - args += (parsed_args,) - return args, kwargs - def use_args( self, argmap, req=None, - *, - location=None, + locations=None, as_kwargs=False, validate=None, error_status_code=None, - error_headers=None + error_headers=None, ): """Decorator that injects parsed arguments into a view function or method. Example usage with Flask: :: @app.route('/echo', methods=['get', 'post']) - @parser.use_args({'name': fields.Str()}, location="querystring") + @parser.use_args({'name': fields.Str()}) def greet(args): return 'Hello ' + args['name'] :param argmap: Either a `marshmallow.Schema`, a `dict` of argname -> `marshmallow.fields.Field` pairs, or a callable which accepts a request and returns a `marshmallow.Schema`. - :param str location: Where on the request to load values. + :param tuple locations: Where on the request to search for values. :param bool as_kwargs: Whether to insert arguments as keyword arguments. :param callable validate: Validation function that receives the dictionary of parsed arguments. If the function returns ``False``, the parser @@ -334,12 +425,12 @@ :param dict error_headers: Headers passed to error handler functions when a a `ValidationError` is raised. """ - location = location or self.location + locations = locations or self.locations request_obj = req # Optimization: If argmap is passed as a dictionary, we only need # to generate a Schema once if isinstance(argmap, Mapping): - argmap = dict2schema(argmap, schema_class=self.schema_class)() + argmap = dict2schema(argmap, self.schema_class)() def decorator(func): req_ = request_obj @@ -350,27 +441,29 @@ if not req_obj: req_obj = self.get_request_from_view_args(func, args, kwargs) - # NOTE: At this point, argmap may be a Schema, or a callable parsed_args = self.parse( argmap, req=req_obj, - location=location, + locations=locations, validate=validate, error_status_code=error_status_code, error_headers=error_headers, ) - args, kwargs = self._update_args_kwargs( - args, kwargs, parsed_args, as_kwargs - ) - return func(*args, **kwargs) + if as_kwargs: + kwargs.update(parsed_args) + return func(*args, **kwargs) + else: + # Add parsed_args after other positional arguments + new_args = args + (parsed_args,) + return func(*new_args, **kwargs) wrapper.__wrapped__ = func return wrapper return decorator - def use_kwargs(self, *args, **kwargs) -> typing.Callable: + def use_kwargs(self, *args, **kwargs): """Decorator that injects parsed arguments into a view function or method as keyword arguments. @@ -388,23 +481,19 @@ kwargs["as_kwargs"] = True return self.use_args(*args, **kwargs) - def location_loader(self, name): - """Decorator that registers a function for loading a request location. - The wrapped function receives a schema and a request. - - The schema will usually not be relevant, but it's important in some - cases -- most notably in order to correctly load multidict values into - list fields. Without the schema, there would be no way to know whether - to simply `.get()` or `.getall()` from a multidict for a given value. + def location_handler(self, name): + """Decorator that registers a function for parsing a request location. + The wrapped function receives a request, the name of the argument, and + the corresponding `Field ` object. Example: :: from webargs import core parser = core.Parser() - @parser.location_loader("name") - def load_data(request, schema): - return request.data + @parser.location_handler("name") + def parse_data(request, name, field): + return request.data.get(name) :param str name: The name of the location to register. """ @@ -434,7 +523,7 @@ @parser.error_handler - def handle_error(error, req, schema, *, status_code, headers): + def handle_error(error, req, schema, status_code, headers): raise CustomError(error.messages) :param callable func: The error callback to register. @@ -442,94 +531,47 @@ self.error_callback = func return func - def _handle_invalid_json_error(self, error, req, *args, **kwargs): - """Internal hook for overriding treatment of JSONDecodeErrors. - - Invoked by default `load_json` implementation. - - External parsers can just implement their own behavior for load_json , - so this is not part of the public parser API. - """ - raise error - - def load_json(self, req, schema): - """Load JSON from a request object or return `missing` if no value can - be found. - """ - # NOTE: although this implementation is real/concrete and used by - # several of the parsers in webargs, it relies on the internal hooks - # `_handle_invalid_json_error` and `_raw_load_json` - # these methods are not part of the public API and are used to simplify - # code sharing amongst the built-in webargs parsers - try: - return self._raw_load_json(req) - except json.JSONDecodeError as exc: - if exc.doc == "": - return missing - return self._handle_invalid_json_error(exc, req) - except UnicodeDecodeError as exc: - return self._handle_invalid_json_error(exc, req) - - def load_json_or_form(self, req, schema): - """Load data from a request, accepting either JSON or form-encoded - data. - - The data will first be loaded as JSON, and, if that fails, it will be - loaded as a form post. - """ - data = self.load_json(req, schema) - if data is not missing: - return data - return self.load_form(req, schema) - # Abstract Methods - def _raw_load_json(self, req): - """Internal hook method for implementing load_json() - - Get a request body for feeding in to `load_json`, and parse it either - using core.parse_json() or similar utilities which raise - JSONDecodeErrors. - Ensure consistent behavior when encountering decoding errors. - - The default implementation here simply returns `missing`, and the default - implementation of `load_json` above will pass that value through. - However, by implementing a "mostly concrete" version of load_json with - this as a hook for getting data, we consolidate the logic for handling - those JSONDecodeErrors. - """ - return missing - - def load_querystring(self, req, schema): - """Load the query string of a request object or return `missing` if no - value can be found. - """ - return missing - - def load_form(self, req, schema): - """Load the form data of a request object or return `missing` if no - value can be found. - """ - return missing - - def load_headers(self, req, schema): - """Load the headers or return `missing` if no value can be found. - """ - return missing - - def load_cookies(self, req, schema): - """Load the cookies from the request or return `missing` if no value - can be found. - """ - return missing - - def load_files(self, req, schema): - """Load files from the request or return `missing` if no values can be - found. - """ - return missing - - def handle_error(self, error, req, schema, *, error_status_code, error_headers): + def parse_json(self, req, name, arg): + """Pull a JSON value from a request object or return `missing` if the + value cannot be found. + """ + return missing + + def parse_querystring(self, req, name, arg): + """Pull a value from the query string of a request object or return `missing` if + the value cannot be found. + """ + return missing + + def parse_form(self, req, name, arg): + """Pull a value from the form data of a request object or return + `missing` if the value cannot be found. + """ + return missing + + def parse_headers(self, req, name, arg): + """Pull a value from the headers or return `missing` if the value + cannot be found. + """ + return missing + + def parse_cookies(self, req, name, arg): + """Pull a cookie value from the request or return `missing` if the value + cannot be found. + """ + return missing + + def parse_files(self, req, name, arg): + """Pull a file from the request or return `missing` if the value file + cannot be found. + """ + return missing + + def handle_error( + self, error, req, schema, error_status_code=None, error_headers=None + ): """Called if an error occurs while parsing args. By default, just logs and raises ``error``. """ diff --git a/src/webargs/dict2schema.py b/src/webargs/dict2schema.py index ca38f32..0300032 100644 --- a/src/webargs/dict2schema.py +++ b/src/webargs/dict2schema.py @@ -1,7 +1,8 @@ +# -*- coding: utf-8 -*- import marshmallow as ma -def dict2schema(dct, *, schema_class=ma.Schema): +def dict2schema(dct, schema_class=ma.Schema): """Generate a `marshmallow.Schema` class given a dictionary of `Fields `. """ @@ -9,8 +10,8 @@ return schema_class.from_dict(dct) attrs = dct.copy() - class Meta: + class Meta(object): strict = True attrs["Meta"] = Meta - return type("", (schema_class,), attrs) + return type(str(""), (schema_class,), attrs) diff --git a/src/webargs/djangoparser.py b/src/webargs/djangoparser.py index 65daee1..cee03d7 100644 --- a/src/webargs/djangoparser.py +++ b/src/webargs/djangoparser.py @@ -1,3 +1,4 @@ +# -*- coding: utf-8 -*- """Django request argument parsing. Example usage: :: @@ -18,11 +19,7 @@ return HttpResponse('Hello ' + args['name']) """ from webargs import core -from webargs.multidictproxy import MultiDictProxy - - -def is_json_request(req): - return core.is_json(req.content_type) +from webargs.core import json class DjangoParser(core.Parser): @@ -36,36 +33,44 @@ the parser and returning the appropriate `HTTPResponse`. """ - def _raw_load_json(self, req): - """Read a json payload from the request for the core parser's load_json + def parse_querystring(self, req, name, field): + """Pull the querystring value from the request.""" + return core.get_value(req.GET, name, field) - Checks the input mimetype and may return 'missing' if the mimetype is - non-json, even if the request body is parseable as json.""" - if not is_json_request(req): - return core.missing + def parse_form(self, req, name, field): + """Pull the form value from the request.""" + return core.get_value(req.POST, name, field) - return core.parse_json(req.body) + def parse_json(self, req, name, field): + """Pull a json value from the request body.""" + json_data = self._cache.get("json") + if json_data is None: + if not core.is_json(req.content_type): + return core.missing - def load_querystring(self, req, schema): - """Return query params from the request as a MultiDictProxy.""" - return MultiDictProxy(req.GET, schema) + try: + self._cache["json"] = json_data = core.parse_json(req.body) + except AttributeError: + return core.missing + except json.JSONDecodeError as e: + if e.doc == "": + return core.missing + else: + return self.handle_invalid_json_error(e, req) + return core.get_value(json_data, name, field, allow_many_nested=True) - def load_form(self, req, schema): - """Return form values from the request as a MultiDictProxy.""" - return MultiDictProxy(req.POST, schema) + def parse_cookies(self, req, name, field): + """Pull the value from the cookiejar.""" + return core.get_value(req.COOKIES, name, field) - def load_cookies(self, req, schema): - """Return cookies from the request.""" - return req.COOKIES - - def load_headers(self, req, schema): + def parse_headers(self, req, name, field): raise NotImplementedError( - "Header parsing not supported by {}".format(self.__class__.__name__) + "Header parsing not supported by {0}".format(self.__class__.__name__) ) - def load_files(self, req, schema): - """Return files from the request as a MultiDictProxy.""" - return MultiDictProxy(req.FILES, schema) + def parse_files(self, req, name, field): + """Pull a file from the request.""" + return core.get_value(req.FILES, name, field) def get_request_from_view_args(self, view, args, kwargs): # The first argument is either `self` or `request` @@ -74,6 +79,9 @@ except AttributeError: # first arg is request return args[0] + def handle_invalid_json_error(self, error, req, *args, **kwargs): + raise error + parser = DjangoParser() use_args = parser.use_args diff --git a/src/webargs/falconparser.py b/src/webargs/falconparser.py index 85c7f1d..b8c5ec7 100644 --- a/src/webargs/falconparser.py +++ b/src/webargs/falconparser.py @@ -1,10 +1,11 @@ +# -*- coding: utf-8 -*- """Falcon request argument parsing module. """ import falcon from falcon.util.uri import parse_query_string from webargs import core -from webargs.multidictproxy import MultiDictProxy +from webargs.core import json HTTP_422 = "422 Unprocessable Entity" @@ -29,13 +30,30 @@ return content_type and core.is_json(content_type) +def parse_json_body(req): + if req.content_length in (None, 0): + # Nothing to do + return {} + if is_json_request(req): + body = req.stream.read() + if body: + try: + return core.parse_json(body) + except json.JSONDecodeError as e: + if e.doc == "": + return core.missing + else: + raise + return {} + + # NOTE: Adapted from falcon.request.Request._parse_form_urlencoded def parse_form_body(req): if ( req.content_type is not None and "application/x-www-form-urlencoded" in req.content_type ): - body = req.stream.read(req.content_length or 0) + body = req.stream.read() try: body = body.decode("ascii") except UnicodeDecodeError: @@ -48,9 +66,10 @@ ) if body: - return parse_query_string(body, keep_blank=req.options.keep_blank_qs_values) - - return core.missing + return parse_query_string( + body, keep_blank_qs_values=req.options.keep_blank_qs_values + ) + return {} class HTTPError(falcon.HTTPError): @@ -59,11 +78,11 @@ def __init__(self, status, errors, *args, **kwargs): self.errors = errors - super().__init__(status, *args, **kwargs) + super(HTTPError, self).__init__(status, *args, **kwargs) def to_dict(self, *args, **kwargs): """Override `falcon.HTTPError` to include error messages in responses.""" - ret = super().to_dict(*args, **kwargs) + ret = super(HTTPError, self).to_dict(*args, **kwargs) if self.errors is not None: ret["errors"] = self.errors return ret @@ -72,77 +91,70 @@ class FalconParser(core.Parser): """Falcon request argument parser.""" - # Note on the use of MultiDictProxy throughout: - # Falcon parses query strings and form values into ordinary dicts, but with - # the values listified where appropriate - # it is still therefore necessary in these cases to wrap them in - # MultiDictProxy because we need to use the schema to determine when single - # values should be wrapped in lists due to the type of the destination - # field + def parse_querystring(self, req, name, field): + """Pull a querystring value from the request.""" + return core.get_value(req.params, name, field) - def load_querystring(self, req, schema): - """Return query params from the request as a MultiDictProxy.""" - return MultiDictProxy(req.params, schema) - - def load_form(self, req, schema): - """Return form values from the request as a MultiDictProxy + def parse_form(self, req, name, field): + """Pull a form value from the request. .. note:: The request stream will be read and left at EOF. """ - form = parse_form_body(req) - if form is core.missing: - return form - return MultiDictProxy(form, schema) + form = self._cache.get("form") + if form is None: + self._cache["form"] = form = parse_form_body(req) + return core.get_value(form, name, field) - def _raw_load_json(self, req): - """Return a json payload from the request for the core parser's load_json + def parse_json(self, req, name, field): + """Pull a JSON body value from the request. - Checks the input mimetype and may return 'missing' if the mimetype is - non-json, even if the request body is parseable as json.""" - if not is_json_request(req) or req.content_length in (None, 0): - return core.missing - body = req.stream.read(req.content_length) - if body: - return core.parse_json(body) - return core.missing + .. note:: - def load_headers(self, req, schema): - """Return headers from the request.""" - # Falcon only exposes headers as a dict (not multidict) - return req.headers + The request stream will be read and left at EOF. + """ + json_data = self._cache.get("json_data") + if json_data is None: + try: + self._cache["json_data"] = json_data = parse_json_body(req) + except json.JSONDecodeError as e: + return self.handle_invalid_json_error(e, req) + return core.get_value(json_data, name, field, allow_many_nested=True) - def load_cookies(self, req, schema): - """Return cookies from the request.""" - # Cookies are expressed in Falcon as a dict, but the possibility of - # multiple values for a cookie is preserved internally -- if desired in - # the future, webargs could add a MultiDict type for Cookies here built - # from (req, schema), but Falcon does not provide one out of the box - return req.cookies + def parse_headers(self, req, name, field): + """Pull a header value from the request.""" + # Use req.get_headers rather than req.headers for performance + return req.get_header(name, required=False) or core.missing + + def parse_cookies(self, req, name, field): + """Pull a cookie value from the request.""" + cookies = self._cache.get("cookies") + if cookies is None: + self._cache["cookies"] = cookies = req.cookies + return core.get_value(cookies, name, field) def get_request_from_view_args(self, view, args, kwargs): """Get request from a resource method's arguments. Assumes that request is the second argument. """ req = args[1] - if not isinstance(req, falcon.Request): - raise TypeError("Argument is not a falcon.Request") + assert isinstance(req, falcon.Request), "Argument is not a falcon.Request" return req - def load_files(self, req, schema): + def parse_files(self, req, name, field): raise NotImplementedError( - "Parsing files not yet supported by {}".format(self.__class__.__name__) + "Parsing files not yet supported by {0}".format(self.__class__.__name__) ) - def handle_error(self, error, req, schema, *, error_status_code, error_headers): + def handle_error(self, error, req, schema, error_status_code, error_headers): """Handles errors during parsing.""" status = status_map.get(error_status_code or self.DEFAULT_VALIDATION_STATUS) if status is None: - raise LookupError("Status code {} not supported".format(error_status_code)) + raise LookupError("Status code {0} not supported".format(error_status_code)) raise HTTPError(status, errors=error.messages, headers=error_headers) - def _handle_invalid_json_error(self, error, req, *args, **kwargs): + def handle_invalid_json_error(self, error, req, *args, **kwargs): status = status_map[400] messages = {"json": ["Invalid JSON body."]} raise HTTPError(status, errors=messages) diff --git a/src/webargs/fields.py b/src/webargs/fields.py index 2227f3f..2828c56 100644 --- a/src/webargs/fields.py +++ b/src/webargs/fields.py @@ -1,3 +1,4 @@ +# -*- coding: utf-8 -*- """Field classes. Includes all fields from `marshmallow.fields` in addition to a custom @@ -9,7 +10,7 @@ .. code-block:: python args = { - "active": fields.Bool(location="query"), + "active": fields.Bool(location='query'), "content_type": fields.Str(data_key="Content-Type", location="headers"), } @@ -40,79 +41,41 @@ def __init__(self, nested, *args, **kwargs): if isinstance(nested, dict): nested = dict2schema(nested) - super().__init__(nested, *args, **kwargs) + super(Nested, self).__init__(nested, *args, **kwargs) -class DelimitedFieldMixin: - """ - This is a mixin class for subclasses of ma.fields.List and ma.fields.Tuple - which split on a pre-specified delimiter. By default, the delimiter will be "," +class DelimitedList(ma.fields.List): + """Same as `marshmallow.fields.List`, except can load from either a list or + a delimited string (e.g. "foo,bar,baz"). - Because we want the MRO to reach this class before the List or Tuple class, - it must be listed first in the superclasses - - For example, a DelimitedList-like type can be defined like so: - - >>> class MyDelimitedList(DelimitedFieldMixin, ma.fields.List): - >>> pass + :param Field cls_or_instance: A field class or instance. + :param str delimiter: Delimiter between values. + :param bool as_string: Dump values to string. """ delimiter = "," + def __init__(self, cls_or_instance, delimiter=None, as_string=False, **kwargs): + self.delimiter = delimiter or self.delimiter + self.as_string = as_string + super(DelimitedList, self).__init__(cls_or_instance, **kwargs) + def _serialize(self, value, attr, obj): - # serializing will start with parent-class serialization, so that we correctly - # output lists of non-primitive types, e.g. DelimitedList(DateTime) - return self.delimiter.join( - format(each) for each in super()._serialize(value, attr, obj) - ) + ret = super(DelimitedList, self)._serialize(value, attr, obj) + if self.as_string: + return self.delimiter.join(format(each) for each in ret) + return ret def _deserialize(self, value, attr, data, **kwargs): - # attempting to deserialize from a non-string source is an error - if not isinstance(value, (str, bytes)): + try: + ret = ( + value + if ma.utils.is_iterable_but_not_string(value) + else value.split(self.delimiter) + ) + except AttributeError: if MARSHMALLOW_VERSION_INFO[0] < 3: self.fail("invalid") else: raise self.make_error("invalid") - return super()._deserialize(value.split(self.delimiter), attr, data, **kwargs) - - -class DelimitedList(DelimitedFieldMixin, ma.fields.List): - """A field which is similar to a List, but takes its input as a delimited - string (e.g. "foo,bar,baz"). - - Like List, it can be given a nested field type which it will use to - de/serialize each element of the list. - - :param Field cls_or_instance: A field class or instance. - :param str delimiter: Delimiter between values. - """ - - default_error_messages = {"invalid": "Not a valid delimited list."} - delimiter = "," - - def __init__(self, cls_or_instance, *, delimiter=None, **kwargs): - self.delimiter = delimiter or self.delimiter - super().__init__(cls_or_instance, **kwargs) - - -# DelimitedTuple can only be defined when using marshmallow3, when Tuple was -# added -if MARSHMALLOW_VERSION_INFO[0] >= 3: - - class DelimitedTuple(DelimitedFieldMixin, ma.fields.Tuple): - """A field which is similar to a Tuple, but takes its input as a delimited - string (e.g. "foo,bar,baz"). - - Like Tuple, it can be given a tuple of nested field types which it will use to - de/serialize each element of the tuple. - - :param Iterable[Field] tuple_fields: An iterable of field classes or instances. - :param str delimiter: Delimiter between values. - """ - - default_error_messages = {"invalid": "Not a valid delimited tuple."} - delimiter = "," - - def __init__(self, tuple_fields, *, delimiter=None, **kwargs): - self.delimiter = delimiter or self.delimiter - super().__init__(tuple_fields, **kwargs) + return super(DelimitedList, self)._deserialize(ret, attr, data, **kwargs) diff --git a/src/webargs/flaskparser.py b/src/webargs/flaskparser.py index 06b1815..9b5c058 100644 --- a/src/webargs/flaskparser.py +++ b/src/webargs/flaskparser.py @@ -1,3 +1,4 @@ +# -*- coding: utf-8 -*- """Flask request argument parsing module. Example: :: @@ -9,23 +10,20 @@ app = Flask(__name__) - user_detail_args = { - 'per_page': fields.Int() + hello_args = { + 'name': fields.Str(required=True) } - @app.route("/user/") - @use_args(user_detail_args) - def user_detail(args, uid): - return ("The user page for user {uid}, showing {per_page} posts.").format( - uid=uid, per_page=args["per_page"] - ) + @app.route('/') + @use_args(hello_args) + def index(args): + return 'Hello ' + args['name'] """ import flask from werkzeug.exceptions import HTTPException from webargs import core -from webargs.compat import MARSHMALLOW_VERSION_INFO -from webargs.multidictproxy import MultiDictProxy +from webargs.core import json def abort(http_status_code, exc=None, **kwargs): @@ -50,63 +48,64 @@ """Flask request argument parser.""" __location_map__ = dict( - view_args="load_view_args", - path="load_view_args", - **core.Parser.__location_map__, + view_args="parse_view_args", + path="parse_view_args", + **core.Parser.__location_map__ ) - def _raw_load_json(self, req): - """Return a json payload from the request for the core parser's load_json + def parse_view_args(self, req, name, field): + """Pull a value from the request's ``view_args``.""" + return core.get_value(req.view_args, name, field) - Checks the input mimetype and may return 'missing' if the mimetype is - non-json, even if the request body is parseable as json.""" - if not is_json_request(req): - return core.missing + def parse_json(self, req, name, field): + """Pull a json value from the request.""" + json_data = self._cache.get("json") + if json_data is None: + if not is_json_request(req): + return core.missing - return core.parse_json(req.get_data(cache=True)) + # We decode the json manually here instead of + # using req.get_json() so that we can handle + # JSONDecodeErrors consistently + data = req.get_data(cache=True) + try: + self._cache["json"] = json_data = core.parse_json(data) + except json.JSONDecodeError as e: + if e.doc == "": + return core.missing + else: + return self.handle_invalid_json_error(e, req) + return core.get_value(json_data, name, field, allow_many_nested=True) - def _handle_invalid_json_error(self, error, req, *args, **kwargs): - abort(400, exc=error, messages={"json": ["Invalid JSON body."]}) + def parse_querystring(self, req, name, field): + """Pull a querystring value from the request.""" + return core.get_value(req.args, name, field) - def load_view_args(self, req, schema): - """Return the request's ``view_args`` or ``missing`` if there are none.""" - return req.view_args or core.missing + def parse_form(self, req, name, field): + """Pull a form value from the request.""" + try: + return core.get_value(req.form, name, field) + except AttributeError: + pass + return core.missing - def load_querystring(self, req, schema): - """Return query params from the request as a MultiDictProxy.""" - return MultiDictProxy(req.args, schema) + def parse_headers(self, req, name, field): + """Pull a value from the header data.""" + return core.get_value(req.headers, name, field) - def load_form(self, req, schema): - """Return form values from the request as a MultiDictProxy.""" - return MultiDictProxy(req.form, schema) + def parse_cookies(self, req, name, field): + """Pull a value from the cookiejar.""" + return core.get_value(req.cookies, name, field) - def load_headers(self, req, schema): - """Return headers from the request as a MultiDictProxy.""" - return MultiDictProxy(req.headers, schema) + def parse_files(self, req, name, field): + """Pull a file from the request.""" + return core.get_value(req.files, name, field) - def load_cookies(self, req, schema): - """Return cookies from the request.""" - return req.cookies - - def load_files(self, req, schema): - """Return files from the request as a MultiDictProxy.""" - return MultiDictProxy(req.files, schema) - - def handle_error(self, error, req, schema, *, error_status_code, error_headers): + def handle_error(self, error, req, schema, error_status_code, error_headers): """Handles errors during parsing. Aborts the current HTTP request and responds with a 422 error. """ status_code = error_status_code or self.DEFAULT_VALIDATION_STATUS - # on marshmallow 2, a many schema receiving a non-list value will - # produce this specific error back -- reformat it to match the - # marshmallow 3 message so that Flask can properly encode it - messages = error.messages - if ( - MARSHMALLOW_VERSION_INFO[0] < 3 - and schema.many - and messages == {0: {}, "_schema": ["Invalid input type."]} - ): - messages.pop(0) abort( status_code, exc=error, @@ -115,8 +114,11 @@ headers=error_headers, ) + def handle_invalid_json_error(self, error, req, *args, **kwargs): + abort(400, exc=error, messages={"json": ["Invalid JSON body."]}) + def get_default_request(self): - """Override to use Flask's thread-local request object by default""" + """Override to use Flask's thread-local request objec by default""" return flask.request diff --git a/src/webargs/multidictproxy.py b/src/webargs/multidictproxy.py deleted file mode 100644 index 6d7c8cc..0000000 --- a/src/webargs/multidictproxy.py +++ /dev/null @@ -1,77 +0,0 @@ -from collections.abc import Mapping - -from webargs.compat import MARSHMALLOW_VERSION_INFO -from webargs.core import missing, is_multiple - - -class MultiDictProxy(Mapping): - """ - A proxy object which wraps multidict types along with a matching schema - Whenever a value is looked up, it is checked against the schema to see if - there is a matching field where `is_multiple` is True. If there is, then - the data should be loaded as a list or tuple. - - In all other cases, __getitem__ proxies directly to the input multidict. - """ - - def __init__(self, multidict, schema): - self.data = multidict - self.multiple_keys = self._collect_multiple_keys(schema) - - @staticmethod - def _collect_multiple_keys(schema): - result = set() - for name, field in schema.fields.items(): - if not is_multiple(field): - continue - if MARSHMALLOW_VERSION_INFO[0] < 3: - result.add(field.load_from if field.load_from is not None else name) - else: - result.add(field.data_key if field.data_key is not None else name) - return result - - def __getitem__(self, key): - val = self.data.get(key, missing) - if val is missing or key not in self.multiple_keys: - return val - if hasattr(self.data, "getlist"): - return self.data.getlist(key) - if hasattr(self.data, "getall"): - return self.data.getall(key) - if isinstance(val, (list, tuple)): - return val - if val is None: - return None - return [val] - - def __str__(self): # str(proxy) proxies to str(proxy.data) - return str(self.data) - - def __repr__(self): - return "MultiDictProxy(data={!r}, multiple_keys={!r})".format( - self.data, self.multiple_keys - ) - - def __delitem__(self, key): - del self.data[key] - - def __setitem__(self, key, value): - self.data[key] = value - - def __getattr__(self, name): - return getattr(self.data, name) - - def __iter__(self): - return iter(self.data) - - def __contains__(self, x): - return x in self.data - - def __len__(self): - return len(self.data) - - def __eq__(self, other): - return self.data == other - - def __ne__(self, other): - return self.data != other diff --git a/src/webargs/pyramidparser.py b/src/webargs/pyramidparser.py index 7f9d5c0..9ee334e 100644 --- a/src/webargs/pyramidparser.py +++ b/src/webargs/pyramidparser.py @@ -1,3 +1,4 @@ +# -*- coding: utf-8 -*- """Pyramid request argument parsing. Example usage: :: @@ -24,99 +25,102 @@ server = make_server('0.0.0.0', 6543, app) server.serve_forever() """ +import collections import functools -from collections.abc import Mapping from webob.multidict import MultiDict from pyramid.httpexceptions import exception_response from webargs import core from webargs.core import json -from webargs.multidictproxy import MultiDictProxy - - -def is_json_request(req): - return core.is_json(req.headers.get("content-type")) +from webargs.compat import text_type class PyramidParser(core.Parser): """Pyramid request argument parser.""" __location_map__ = dict( - matchdict="load_matchdict", - path="load_matchdict", - **core.Parser.__location_map__, + matchdict="parse_matchdict", + path="parse_matchdict", + **core.Parser.__location_map__ ) - def _raw_load_json(self, req): - """Return a json payload from the request for the core parser's load_json + def parse_querystring(self, req, name, field): + """Pull a querystring value from the request.""" + return core.get_value(req.GET, name, field) - Checks the input mimetype and may return 'missing' if the mimetype is - non-json, even if the request body is parseable as json.""" - if not is_json_request(req): - return core.missing + def parse_form(self, req, name, field): + """Pull a form value from the request.""" + return core.get_value(req.POST, name, field) - return core.parse_json(req.body, encoding=req.charset) + def parse_json(self, req, name, field): + """Pull a json value from the request.""" + json_data = self._cache.get("json") + if json_data is None: + if not core.is_json(req.content_type): + return core.missing - def load_querystring(self, req, schema): - """Return query params from the request as a MultiDictProxy.""" - return MultiDictProxy(req.GET, schema) + try: + self._cache["json"] = json_data = core.parse_json(req.body, req.charset) + except json.JSONDecodeError as e: + if e.doc == "": + return core.missing + else: + return self.handle_invalid_json_error(e, req) + if json_data is None: + return core.missing + return core.get_value(json_data, name, field, allow_many_nested=True) - def load_form(self, req, schema): - """Return form values from the request as a MultiDictProxy.""" - return MultiDictProxy(req.POST, schema) + def parse_cookies(self, req, name, field): + """Pull the value from the cookiejar.""" + return core.get_value(req.cookies, name, field) - def load_cookies(self, req, schema): - """Return cookies from the request as a MultiDictProxy.""" - return MultiDictProxy(req.cookies, schema) + def parse_headers(self, req, name, field): + """Pull a value from the header data.""" + return core.get_value(req.headers, name, field) - def load_headers(self, req, schema): - """Return headers from the request as a MultiDictProxy.""" - return MultiDictProxy(req.headers, schema) + def parse_files(self, req, name, field): + """Pull a file from the request.""" + files = ((k, v) for k, v in req.POST.items() if hasattr(v, "file")) + return core.get_value(MultiDict(files), name, field) - def load_files(self, req, schema): - """Return files from the request as a MultiDictProxy.""" - files = ((k, v) for k, v in req.POST.items() if hasattr(v, "file")) - return MultiDictProxy(MultiDict(files), schema) + def parse_matchdict(self, req, name, field): + """Pull a value from the request's `matchdict`.""" + return core.get_value(req.matchdict, name, field) - def load_matchdict(self, req, schema): - """Return the request's ``matchdict`` as a MultiDictProxy.""" - return MultiDictProxy(req.matchdict, schema) - - def handle_error(self, error, req, schema, *, error_status_code, error_headers): + def handle_error(self, error, req, schema, error_status_code, error_headers): """Handles errors during parsing. Aborts the current HTTP request and responds with a 400 error. """ status_code = error_status_code or self.DEFAULT_VALIDATION_STATUS response = exception_response( status_code, - detail=str(error), + detail=text_type(error), headers=error_headers, content_type="application/json", ) body = json.dumps(error.messages) - response.body = body.encode("utf-8") if isinstance(body, str) else body + response.body = body.encode("utf-8") if isinstance(body, text_type) else body raise response - def _handle_invalid_json_error(self, error, req, *args, **kwargs): + def handle_invalid_json_error(self, error, req, *args, **kwargs): messages = {"json": ["Invalid JSON body."]} response = exception_response( - 400, detail=str(messages), content_type="application/json" + 400, detail=text_type(messages), content_type="application/json" ) body = json.dumps(messages) - response.body = body.encode("utf-8") if isinstance(body, str) else body + response.body = body.encode("utf-8") if isinstance(body, text_type) else body raise response def use_args( self, argmap, req=None, - *, - location=core.Parser.DEFAULT_LOCATION, + locations=core.Parser.DEFAULT_LOCATIONS, as_kwargs=False, validate=None, error_status_code=None, - error_headers=None + error_headers=None, ): """Decorator that injects parsed arguments into a view callable. Supports the *Class-based View* pattern where `request` is saved as an instance @@ -126,7 +130,7 @@ of argname -> `marshmallow.fields.Field` pairs, or a callable which accepts a request and returns a `marshmallow.Schema`. :param req: The request object to parse. Pulled off of the view by default. - :param str location: Where on the request to load values. + :param tuple locations: Where on the request to search for values. :param bool as_kwargs: Whether to insert arguments as keyword arguments. :param callable validate: Validation function that receives the dictionary of parsed arguments. If the function returns ``False``, the parser @@ -136,11 +140,11 @@ :param dict error_headers: Headers passed to error handler functions when a a `ValidationError` is raised. """ - location = location or self.location + locations = locations or self.locations # Optimization: If argmap is passed as a dictionary, we only need # to generate a Schema once - if isinstance(argmap, Mapping): - argmap = core.dict2schema(argmap, schema_class=self.schema_class)() + if isinstance(argmap, collections.Mapping): + argmap = core.dict2schema(argmap, self.schema_class)() def decorator(func): @functools.wraps(func) @@ -154,15 +158,16 @@ parsed_args = self.parse( argmap, req=request, - location=location, + locations=locations, validate=validate, error_status_code=error_status_code, error_headers=error_headers, ) - args, kwargs = self._update_args_kwargs( - args, kwargs, parsed_args, as_kwargs - ) - return func(obj, *args, **kwargs) + if as_kwargs: + kwargs.update(parsed_args) + return func(obj, *args, **kwargs) + else: + return func(obj, parsed_args, *args, **kwargs) wrapper.__wrapped__ = func return wrapper diff --git a/src/webargs/testing.py b/src/webargs/testing.py index ca04040..3d0f6fb 100644 --- a/src/webargs/testing.py +++ b/src/webargs/testing.py @@ -1,3 +1,4 @@ +# -*- coding: utf-8 -*- """Utilities for testing. Includes a base test class for testing parsers. @@ -12,7 +13,7 @@ from webargs.core import json -class CommonTestCase: +class CommonTestCase(object): """Base test class that defines test methods for common functionality across all parsers. Subclasses must define `create_app`, which returns a WSGI-like app. """ @@ -39,35 +40,24 @@ def test_parse_querystring_args(self, testapp): assert testapp.get("/echo?name=Fred").json == {"name": "Fred"} + def test_parse_querystring_with_query_location_specified(self, testapp): + assert testapp.get("/echo_query?name=Steve").json == {"name": "Steve"} + def test_parse_form(self, testapp): - assert testapp.post("/echo_form", {"name": "Joe"}).json == {"name": "Joe"} + assert testapp.post("/echo", {"name": "Joe"}).json == {"name": "Joe"} def test_parse_json(self, testapp): - assert testapp.post_json("/echo_json", {"name": "Fred"}).json == { - "name": "Fred" - } - - def test_parse_json_missing(self, testapp): - assert testapp.post("/echo_json", "").json == {"name": "World"} - - def test_parse_json_or_form(self, testapp): - assert testapp.post_json("/echo_json_or_form", {"name": "Fred"}).json == { - "name": "Fred" - } - assert testapp.post("/echo_json_or_form", {"name": "Joe"}).json == { - "name": "Joe" - } - assert testapp.post("/echo_json_or_form", "").json == {"name": "World"} + assert testapp.post_json("/echo", {"name": "Fred"}).json == {"name": "Fred"} def test_parse_querystring_default(self, testapp): assert testapp.get("/echo").json == {"name": "World"} def test_parse_json_default(self, testapp): - assert testapp.post_json("/echo_json", {}).json == {"name": "World"} + assert testapp.post_json("/echo", {}).json == {"name": "World"} def test_parse_json_with_charset(self, testapp): res = testapp.post( - "/echo_json", + "/echo", json.dumps({"name": "Steve"}), content_type="application/json;charset=UTF-8", ) @@ -75,27 +65,23 @@ def test_parse_json_with_vendor_media_type(self, testapp): res = testapp.post( - "/echo_json", + "/echo", json.dumps({"name": "Steve"}), content_type="application/vnd.api+json;charset=UTF-8", ) assert res.json == {"name": "Steve"} - def test_parse_ignore_extra_data(self, testapp): - assert testapp.post_json( - "/echo_ignoring_extra_data", {"extra": "data"} - ).json == {"name": "World"} - - def test_parse_json_empty(self, testapp): - assert testapp.post_json("/echo_json", {}).json == {"name": "World"} - - def test_parse_json_error_unexpected_int(self, testapp): - res = testapp.post_json("/echo_json", 1, expect_errors=True) - assert res.status_code == 422 - - def test_parse_json_error_unexpected_list(self, testapp): - res = testapp.post_json("/echo_json", [{"extra": "data"}], expect_errors=True) - assert res.status_code == 422 + def test_parse_json_ignores_extra_data(self, testapp): + assert testapp.post_json("/echo", {"extra": "data"}).json == {"name": "World"} + + def test_parse_json_blank(self, testapp): + assert testapp.post_json("/echo", None).json == {"name": "World"} + + def test_parse_json_ignore_unexpected_int(self, testapp): + assert testapp.post_json("/echo", 1).json == {"name": "World"} + + def test_parse_json_ignore_unexpected_list(self, testapp): + assert testapp.post_json("/echo", [{"extra": "data"}]).json == {"name": "World"} def test_parse_json_many_schema_invalid_input(self, testapp): res = testapp.post_json( @@ -107,54 +93,34 @@ res = testapp.post_json("/echo_many_schema", [{"name": "Steve"}]).json assert res == [{"name": "Steve"}] - def test_parse_json_many_schema_error_malformed_data(self, testapp): - res = testapp.post_json( - "/echo_many_schema", {"extra": "data"}, expect_errors=True - ) - assert res.status_code == 422 + def test_parse_json_many_schema_ignore_malformed_data(self, testapp): + assert testapp.post_json("/echo_many_schema", {"extra": "data"}).json == [] def test_parsing_form_default(self, testapp): - assert testapp.post("/echo_form", {}).json == {"name": "World"} + assert testapp.post("/echo", {}).json == {"name": "World"} def test_parse_querystring_multiple(self, testapp): expected = {"name": ["steve", "Loria"]} assert testapp.get("/echo_multi?name=steve&name=Loria").json == expected - # test that passing a single value parses correctly - # on parsers like falconparser, where there is no native MultiDict type, - # this verifies the usage of MultiDictProxy to ensure that single values - # are "listified" - def test_parse_querystring_multiple_single_value(self, testapp): - expected = {"name": ["steve"]} - assert testapp.get("/echo_multi?name=steve").json == expected - def test_parse_form_multiple(self, testapp): expected = {"name": ["steve", "Loria"]} assert ( - testapp.post("/echo_multi_form", {"name": ["steve", "Loria"]}).json - == expected + testapp.post("/echo_multi", {"name": ["steve", "Loria"]}).json == expected ) def test_parse_json_list(self, testapp): expected = {"name": ["Steve"]} - assert ( - testapp.post_json("/echo_multi_json", {"name": ["Steve"]}).json == expected - ) - - def test_parse_json_list_error_malformed_data(self, testapp): - res = testapp.post_json( - "/echo_multi_json", {"name": "Steve"}, expect_errors=True - ) - assert res.status_code == 422 + assert testapp.post_json("/echo_multi", {"name": "Steve"}).json == expected def test_parse_json_with_nonascii_chars(self, testapp): - text = "øˆƒ£ºº∆ƒˆ∆" - assert testapp.post_json("/echo_json", {"name": text}).json == {"name": text} + text = u"øˆƒ£ºº∆ƒˆ∆" + assert testapp.post_json("/echo", {"name": text}).json == {"name": text} # https://github.com/marshmallow-code/webargs/issues/427 def test_parse_json_with_nonutf8_chars(self, testapp): res = testapp.post( - "/echo_json", + "/echo", b"\xfe", headers={"Accept": "application/json", "Content-Type": "application/json"}, expect_errors=True, @@ -164,7 +130,7 @@ assert res.json == {"json": ["Invalid JSON body."]} def test_validation_error_returns_422_response(self, testapp): - res = testapp.post_json("/echo_json", {"name": "b"}, expect_errors=True) + res = testapp.post("/echo", {"name": "b"}, expect_errors=True) assert res.status_code == 422 def test_user_validation_error_returns_422_response_by_default(self, testapp): @@ -221,6 +187,10 @@ res = testapp.post_json("/echo_nested_many", in_data) assert res.json == {} + def test_parse_json_if_no_json(self, testapp): + res = testapp.post("/echo") + assert res.json == {"name": "World"} + def test_parse_files(self, testapp): res = testapp.post( "/echo_file", {"myfile": webtest.Upload("README.rst", b"data")} @@ -229,24 +199,18 @@ # https://github.com/sloria/webargs/pull/297 def test_empty_json(self, testapp): - res = testapp.post("/echo_json") + res = testapp.post( + "/echo", + "", + headers={"Accept": "application/json", "Content-Type": "application/json"}, + ) assert res.status_code == 200 assert res.json == {"name": "World"} - # https://github.com/sloria/webargs/pull/297 - def test_empty_json_with_headers(self, testapp): - res = testapp.post( - "/echo_json", - "", - headers={"Accept": "application/json", "Content-Type": "application/json"}, - ) - assert res.status_code == 200 - assert res.json == {"name": "World"} - # https://github.com/sloria/webargs/issues/329 def test_invalid_json(self, testapp): res = testapp.post( - "/echo_json", + "/echo", '{"foo": "bar", }', headers={"Accept": "application/json", "Content-Type": "application/json"}, expect_errors=True, diff --git a/src/webargs/tornadoparser.py b/src/webargs/tornadoparser.py index 4c919a0..984c1e5 100644 --- a/src/webargs/tornadoparser.py +++ b/src/webargs/tornadoparser.py @@ -1,3 +1,4 @@ +# -*- coding: utf-8 -*- """Tornado request argument parsing module. Example: :: @@ -14,11 +15,11 @@ self.write(response) """ import tornado.web -import tornado.concurrent from tornado.escape import _unicode from webargs import core -from webargs.multidictproxy import MultiDictProxy +from webargs.compat import basestring +from webargs.core import json class HTTPError(tornado.web.HTTPError): @@ -27,97 +28,98 @@ def __init__(self, *args, **kwargs): self.messages = kwargs.pop("messages", {}) self.headers = kwargs.pop("headers", None) - super().__init__(*args, **kwargs) + super(HTTPError, self).__init__(*args, **kwargs) -def is_json_request(req): +def parse_json_body(req): + """Return the decoded JSON body from the request.""" content_type = req.headers.get("Content-Type") - return content_type is not None and core.is_json(content_type) + if content_type and core.is_json(content_type): + try: + return core.parse_json(req.body) + except TypeError: + pass + except json.JSONDecodeError as e: + if e.doc == "": + return core.missing + else: + raise + return {} -class WebArgsTornadoMultiDictProxy(MultiDictProxy): +# From tornado.web.RequestHandler.decode_argument +def decode_argument(value, name=None): + """Decodes an argument from the request. """ - Override class for Tornado multidicts, handles argument decoding - requirements. - """ - - def __getitem__(self, key): - try: - value = self.data.get(key, core.missing) - if value is core.missing: - return core.missing - if key in self.multiple_keys: - return [ - _unicode(v) if isinstance(v, (str, bytes)) else v for v in value - ] - if value and isinstance(value, (list, tuple)): - value = value[0] - - if isinstance(value, (str, bytes)): - return _unicode(value) - return value - # based on tornado.web.RequestHandler.decode_argument - except UnicodeDecodeError: - raise HTTPError(400, "Invalid unicode in {}: {!r}".format(key, value[:40])) + try: + return _unicode(value) + except UnicodeDecodeError: + raise HTTPError(400, "Invalid unicode in %s: %r" % (name or "url", value[:40])) -class WebArgsTornadoCookiesMultiDictProxy(MultiDictProxy): +def get_value(d, name, field): + """Handle gets from 'multidicts' made of lists + + It handles cases: ``{"key": [value]}`` and ``{"key": value}`` """ - And a special override for cookies because they come back as objects with a - `value` attribute we need to extract. - Also, does not use the `_unicode` decoding step - """ - - def __getitem__(self, key): - cookie = self.data.get(key, core.missing) - if cookie is core.missing: - return core.missing - if key in self.multiple_keys: - return [cookie.value] - return cookie.value + multiple = core.is_multiple(field) + value = d.get(name, core.missing) + if value is core.missing: + return core.missing + if multiple and value is not core.missing: + return [ + decode_argument(v, name) if isinstance(v, basestring) else v for v in value + ] + ret = value + if value and isinstance(value, (list, tuple)): + ret = value[0] + if isinstance(ret, basestring): + return decode_argument(ret, name) + else: + return ret class TornadoParser(core.Parser): """Tornado request argument parser.""" - def _raw_load_json(self, req): - """Return a json payload from the request for the core parser's load_json + def parse_json(self, req, name, field): + """Pull a json value from the request.""" + json_data = self._cache.get("json") + if json_data is None: + try: + self._cache["json"] = json_data = parse_json_body(req) + except json.JSONDecodeError as e: + return self.handle_invalid_json_error(e, req) + if json_data is None: + return core.missing + return core.get_value(json_data, name, field, allow_many_nested=True) - Checks the input mimetype and may return 'missing' if the mimetype is - non-json, even if the request body is parseable as json.""" - if not is_json_request(req): - return core.missing + def parse_querystring(self, req, name, field): + """Pull a querystring value from the request.""" + return get_value(req.query_arguments, name, field) - # request.body may be a concurrent.Future on streaming requests - # this would cause a TypeError if we try to parse it - if isinstance(req.body, tornado.concurrent.Future): - return core.missing + def parse_form(self, req, name, field): + """Pull a form value from the request.""" + return get_value(req.body_arguments, name, field) - return core.parse_json(req.body) + def parse_headers(self, req, name, field): + """Pull a value from the header data.""" + return get_value(req.headers, name, field) - def load_querystring(self, req, schema): - """Return query params from the request as a MultiDictProxy.""" - return WebArgsTornadoMultiDictProxy(req.query_arguments, schema) + def parse_cookies(self, req, name, field): + """Pull a value from the header data.""" + cookie = req.cookies.get(name) - def load_form(self, req, schema): - """Return form values from the request as a MultiDictProxy.""" - return WebArgsTornadoMultiDictProxy(req.body_arguments, schema) + if cookie is not None: + return [cookie.value] if core.is_multiple(field) else cookie.value + else: + return [] if core.is_multiple(field) else None - def load_headers(self, req, schema): - """Return headers from the request as a MultiDictProxy.""" - return WebArgsTornadoMultiDictProxy(req.headers, schema) + def parse_files(self, req, name, field): + """Pull a file from the request.""" + return get_value(req.files, name, field) - def load_cookies(self, req, schema): - """Return cookies from the request as a MultiDictProxy.""" - # use the specialized subclass specifically for handling Tornado - # cookies - return WebArgsTornadoCookiesMultiDictProxy(req.cookies, schema) - - def load_files(self, req, schema): - """Return files from the request as a MultiDictProxy.""" - return WebArgsTornadoMultiDictProxy(req.files, schema) - - def handle_error(self, error, req, schema, *, error_status_code, error_headers): + def handle_error(self, error, req, schema, error_status_code, error_headers): """Handles errors during parsing. Raises a `tornado.web.HTTPError` with a 400 error. """ @@ -134,7 +136,7 @@ headers=error_headers, ) - def _handle_invalid_json_error(self, error, req, *args, **kwargs): + def handle_invalid_json_error(self, error, req, *args, **kwargs): raise HTTPError( 400, log_message="Invalid JSON body.", diff --git a/src/webargs/webapp2parser.py b/src/webargs/webapp2parser.py index e06ad1b..caeacef 100644 --- a/src/webargs/webapp2parser.py +++ b/src/webargs/webapp2parser.py @@ -1,3 +1,4 @@ +# -*- coding: utf-8 -*- """Webapp2 request argument parsing module. Example: :: @@ -30,38 +31,48 @@ import webob.multidict from webargs import core -from webargs.multidictproxy import MultiDictProxy +from webargs.core import json class Webapp2Parser(core.Parser): """webapp2 request argument parser.""" - def _raw_load_json(self, req): - """Return a json payload from the request for the core parser's load_json.""" - if not core.is_json(req.content_type): - return core.missing - return core.parse_json(req.body) + def parse_json(self, req, name, field): + """Pull a json value from the request.""" + json_data = self._cache.get("json") + if json_data is None: + if not core.is_json(req.content_type): + return core.missing - def load_querystring(self, req, schema): - """Return query params from the request as a MultiDictProxy.""" - return MultiDictProxy(req.GET, schema) + try: + self._cache["json"] = json_data = core.parse_json(req.body) + except json.JSONDecodeError as e: + if e.doc == "": + return core.missing + else: + raise + return core.get_value(json_data, name, field, allow_many_nested=True) - def load_form(self, req, schema): - """Return form values from the request as a MultiDictProxy.""" - return MultiDictProxy(req.POST, schema) + def parse_querystring(self, req, name, field): + """Pull a querystring value from the request.""" + return core.get_value(req.GET, name, field) - def load_cookies(self, req, schema): - """Return cookies from the request as a MultiDictProxy.""" - return MultiDictProxy(req.cookies, schema) + def parse_form(self, req, name, field): + """Pull a form value from the request.""" + return core.get_value(req.POST, name, field) - def load_headers(self, req, schema): - """Return headers from the request as a MultiDictProxy.""" - return MultiDictProxy(req.headers, schema) + def parse_cookies(self, req, name, field): + """Pull the value from the cookiejar.""" + return core.get_value(req.cookies, name, field) - def load_files(self, req, schema): - """Return files from the request as a MultiDictProxy.""" + def parse_headers(self, req, name, field): + """Pull a value from the header data.""" + return core.get_value(req.headers, name, field) + + def parse_files(self, req, name, field): + """Pull a file from the request.""" files = ((k, v) for k, v in req.POST.items() if hasattr(v, "file")) - return MultiDictProxy(webob.multidict.MultiDict(files), schema) + return core.get_value(webob.multidict.MultiDict(files), name, field) def get_default_request(self): return webapp2.get_request() diff --git a/tests/__init__.py b/tests/__init__.py index e69de29..40a96af 100755 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -0,0 +1 @@ +# -*- coding: utf-8 -*- diff --git a/tests/apps/aiohttp_app.py b/tests/apps/aiohttp_app.py index a0b3807..b72464e 100644 --- a/tests/apps/aiohttp_app.py +++ b/tests/apps/aiohttp_app.py @@ -2,6 +2,7 @@ import aiohttp from aiohttp.web import json_response +from aiohttp import web import marshmallow as ma from webargs import fields @@ -24,101 +25,72 @@ strict_kwargs = {"strict": True} if MARSHMALLOW_VERSION_INFO[0] < 3 else {} hello_many_schema = HelloSchema(many=True, **strict_kwargs) -# variant which ignores unknown fields -exclude_kwargs = ( - {"strict": True} if MARSHMALLOW_VERSION_INFO[0] < 3 else {"unknown": ma.EXCLUDE} -) -hello_exclude_schema = HelloSchema(**exclude_kwargs) - - ##### Handlers ##### async def echo(request): - parsed = await parser.parse(hello_args, request, location="query") - return json_response(parsed) - - -async def echo_form(request): - parsed = await parser.parse(hello_args, request, location="form") - return json_response(parsed) - - -async def echo_json(request): try: - parsed = await parser.parse(hello_args, request, location="json") + parsed = await parser.parse(hello_args, request) except json.JSONDecodeError: - raise aiohttp.web.HTTPBadRequest( + raise web.HTTPBadRequest( body=json.dumps(["Invalid JSON."]).encode("utf-8"), content_type="application/json", ) return json_response(parsed) -async def echo_json_or_form(request): - try: - parsed = await parser.parse(hello_args, request, location="json_or_form") - except json.JSONDecodeError: - raise aiohttp.web.HTTPBadRequest( - body=json.dumps(["Invalid JSON."]).encode("utf-8"), - content_type="application/json", - ) - return json_response(parsed) - - -@use_args(hello_args, location="query") +async def echo_query(request): + parsed = await parser.parse(hello_args, request, locations=("query",)) + return json_response(parsed) + + +async def echo_json(request): + parsed = await parser.parse(hello_args, request, locations=("json",)) + return json_response(parsed) + + +async def echo_form(request): + parsed = await parser.parse(hello_args, request, locations=("form",)) + return json_response(parsed) + + +@use_args(hello_args) async def echo_use_args(request, args): return json_response(args) -@use_kwargs(hello_args, location="query") +@use_kwargs(hello_args) async def echo_use_kwargs(request, name): return json_response({"name": name}) -@use_args( - {"value": fields.Int()}, validate=lambda args: args["value"] > 42, location="form" -) +@use_args({"value": fields.Int()}, validate=lambda args: args["value"] > 42) async def echo_use_args_validated(request, args): return json_response(args) -async def echo_ignoring_extra_data(request): - return json_response(await parser.parse(hello_exclude_schema, request)) - - async def echo_multi(request): - parsed = await parser.parse(hello_multiple, request, location="query") - return json_response(parsed) - - -async def echo_multi_form(request): - parsed = await parser.parse(hello_multiple, request, location="form") - return json_response(parsed) - - -async def echo_multi_json(request): parsed = await parser.parse(hello_multiple, request) return json_response(parsed) async def echo_many_schema(request): - parsed = await parser.parse(hello_many_schema, request) - return json_response(parsed) - - -@use_args({"value": fields.Int()}, location="query") + parsed = await parser.parse(hello_many_schema, request, locations=("json",)) + return json_response(parsed) + + +@use_args({"value": fields.Int()}) async def echo_use_args_with_path_param(request, args): return json_response(args) -@use_kwargs({"value": fields.Int()}, location="query") +@use_kwargs({"value": fields.Int()}) async def echo_use_kwargs_with_path_param(request, value): return json_response({"value": value}) -@use_args({"page": fields.Int(), "q": fields.Int()}, location="query") -@use_args({"name": fields.Str()}) +@use_args({"page": fields.Int(), "q": fields.Int()}, locations=("query",)) +@use_args({"name": fields.Str()}, locations=("json",)) async def echo_use_args_multiple(request, query_parsed, json_parsed): return json_response({"query_parsed": query_parsed, "json_parsed": json_parsed}) @@ -133,12 +105,12 @@ async def echo_headers(request): - parsed = await parser.parse(hello_exclude_schema, request, location="headers") + parsed = await parser.parse(hello_args, request, locations=("headers",)) return json_response(parsed) async def echo_cookie(request): - parsed = await parser.parse(hello_args, request, location="cookies") + parsed = await parser.parse(hello_args, request, locations=("cookies",)) return json_response(parsed) @@ -172,27 +144,25 @@ async def echo_match_info(request): - parsed = await parser.parse( - {"mymatch": fields.Int()}, request, location="match_info" - ) + parsed = await parser.parse({"mymatch": fields.Int(location="match_info")}, request) return json_response(parsed) class EchoHandler: - @use_args(hello_args, location="query") + @use_args(hello_args) async def get(self, request, args): return json_response(args) -class EchoHandlerView(aiohttp.web.View): +class EchoHandlerView(web.View): @asyncio.coroutine - @use_args(hello_args, location="query") + @use_args(hello_args) def get(self, args): return json_response(args) @asyncio.coroutine -@use_args(HelloSchema, as_kwargs=True, location="query") +@use_args(HelloSchema, as_kwargs=True) def echo_use_schema_as_kwargs(request, name): return json_response({"name": name}) @@ -208,17 +178,14 @@ def create_app(): app = aiohttp.web.Application() - add_route(app, ["GET"], "/echo", echo) + add_route(app, ["GET", "POST"], "/echo", echo) + add_route(app, ["GET"], "/echo_query", echo_query) + add_route(app, ["POST"], "/echo_json", echo_json) add_route(app, ["POST"], "/echo_form", echo_form) - add_route(app, ["POST"], "/echo_json", echo_json) - add_route(app, ["POST"], "/echo_json_or_form", echo_json_or_form) - add_route(app, ["GET"], "/echo_use_args", echo_use_args) - add_route(app, ["GET"], "/echo_use_kwargs", echo_use_kwargs) - add_route(app, ["POST"], "/echo_use_args_validated", echo_use_args_validated) - add_route(app, ["POST"], "/echo_ignoring_extra_data", echo_ignoring_extra_data) - add_route(app, ["GET"], "/echo_multi", echo_multi) - add_route(app, ["POST"], "/echo_multi_form", echo_multi_form) - add_route(app, ["POST"], "/echo_multi_json", echo_multi_json) + add_route(app, ["GET", "POST"], "/echo_use_args", echo_use_args) + add_route(app, ["GET", "POST"], "/echo_use_kwargs", echo_use_kwargs) + add_route(app, ["GET", "POST"], "/echo_use_args_validated", echo_use_args_validated) + add_route(app, ["GET", "POST"], "/echo_multi", echo_multi) add_route(app, ["GET", "POST"], "/echo_many_schema", echo_many_schema) add_route( app, diff --git a/tests/apps/bottle_app.py b/tests/apps/bottle_app.py index f03ef9c..da13c84 100644 --- a/tests/apps/bottle_app.py +++ b/tests/apps/bottle_app.py @@ -1,10 +1,10 @@ +from webargs.core import json from bottle import Bottle, HTTPResponse, debug, request, response import marshmallow as ma from webargs import fields from webargs.bottleparser import parser, use_args, use_kwargs -from webargs.core import json, MARSHMALLOW_VERSION_INFO - +from webargs.core import MARSHMALLOW_VERSION_INFO hello_args = {"name": fields.Str(missing="World", validate=lambda n: len(n) >= 3)} hello_multiple = {"name": fields.List(fields.Str())} @@ -17,100 +17,71 @@ strict_kwargs = {"strict": True} if MARSHMALLOW_VERSION_INFO[0] < 3 else {} hello_many_schema = HelloSchema(many=True, **strict_kwargs) -# variant which ignores unknown fields -exclude_kwargs = ( - {"strict": True} if MARSHMALLOW_VERSION_INFO[0] < 3 else {"unknown": ma.EXCLUDE} -) -hello_exclude_schema = HelloSchema(**exclude_kwargs) - app = Bottle() debug(True) -@app.route("/echo", method=["GET"]) +@app.route("/echo", method=["GET", "POST"]) def echo(): - return parser.parse(hello_args, request, location="query") + return parser.parse(hello_args, request) + + +@app.route("/echo_query") +def echo_query(): + return parser.parse(hello_args, request, locations=("query",)) + + +@app.route("/echo_json", method=["POST"]) +def echo_json(): + return parser.parse(hello_args, request, locations=("json",)) @app.route("/echo_form", method=["POST"]) def echo_form(): - return parser.parse(hello_args, location="form") + return parser.parse(hello_args, request, locations=("form",)) -@app.route("/echo_json", method=["POST"]) -def echo_json(): - return parser.parse(hello_args, location="json") - - -@app.route("/echo_json_or_form", method=["POST"]) -def echo_json_or_form(): - return parser.parse(hello_args, location="json_or_form") - - -@app.route("/echo_use_args", method=["GET"]) -@use_args(hello_args, location="query") +@app.route("/echo_use_args", method=["GET", "POST"]) +@use_args(hello_args) def echo_use_args(args): return args +@app.route("/echo_use_kwargs", method=["GET", "POST"], apply=use_kwargs(hello_args)) +def echo_use_kwargs(name): + return {"name": name} + + @app.route( "/echo_use_args_validated", - method=["POST"], - apply=use_args( - {"value": fields.Int()}, - validate=lambda args: args["value"] > 42, - location="form", - ), + method=["GET", "POST"], + apply=use_args({"value": fields.Int()}, validate=lambda args: args["value"] > 42), ) def echo_use_args_validated(args): return args -@app.route("/echo_ignoring_extra_data", method=["POST"]) -def echo_json_ignore_extra_data(): - return parser.parse(hello_exclude_schema) +@app.route("/echo_multi", method=["GET", "POST"]) +def echo_multi(): + return parser.parse(hello_multiple, request) -@app.route( - "/echo_use_kwargs", method=["GET"], apply=use_kwargs(hello_args, location="query") -) -def echo_use_kwargs(name): - return {"name": name} - - -@app.route("/echo_multi", method=["GET"]) -def echo_multi(): - return parser.parse(hello_multiple, request, location="query") - - -@app.route("/echo_multi_form", method=["POST"]) -def multi_form(): - return parser.parse(hello_multiple, location="form") - - -@app.route("/echo_multi_json", method=["POST"]) -def multi_json(): - return parser.parse(hello_multiple) - - -@app.route("/echo_many_schema", method=["POST"]) +@app.route("/echo_many_schema", method=["GET", "POST"]) def echo_many_schema(): - arguments = parser.parse(hello_many_schema, request) + arguments = parser.parse(hello_many_schema, request, locations=("json",)) return HTTPResponse(body=json.dumps(arguments), content_type="application/json") @app.route( - "/echo_use_args_with_path_param/", - apply=use_args({"value": fields.Int()}, location="query"), + "/echo_use_args_with_path_param/", apply=use_args({"value": fields.Int()}) ) def echo_use_args_with_path_param(args, name): return args @app.route( - "/echo_use_kwargs_with_path_param/", - apply=use_kwargs({"value": fields.Int()}, location="query"), + "/echo_use_kwargs_with_path_param/", apply=use_kwargs({"value": fields.Int()}) ) def echo_use_kwargs_with_path_param(name, value): return {"value": value} @@ -127,20 +98,18 @@ @app.route("/echo_headers") def echo_headers(): - # the "exclude schema" must be used in this case because WSGI headers may - # be populated with many fields not sent by the caller - return parser.parse(hello_exclude_schema, request, location="headers") + return parser.parse(hello_args, request, locations=("headers",)) @app.route("/echo_cookie") def echo_cookie(): - return parser.parse(hello_args, request, location="cookies") + return parser.parse(hello_args, request, locations=("cookies",)) @app.route("/echo_file", method=["POST"]) def echo_file(): args = {"myfile": fields.Field()} - result = parser.parse(args, location="files") + result = parser.parse(args, locations=("files",)) myfile = result["myfile"] content = myfile.file.read().decode("utf8") return {"myfile": content} diff --git a/tests/apps/django_app/base/settings.py b/tests/apps/django_app/base/settings.py index 0dd41b0..a127df7 100644 --- a/tests/apps/django_app/base/settings.py +++ b/tests/apps/django_app/base/settings.py @@ -7,7 +7,7 @@ TEMPLATE_DEBUG = True -ALLOWED_HOSTS = ["*"] +ALLOWED_HOSTS = [] # Application definition INSTALLED_APPS = ("django.contrib.contenttypes",) diff --git a/tests/apps/django_app/base/urls.py b/tests/apps/django_app/base/urls.py index 07a86e9..dfb6745 100644 --- a/tests/apps/django_app/base/urls.py +++ b/tests/apps/django_app/base/urls.py @@ -2,19 +2,14 @@ from tests.apps.django_app.echo import views - urlpatterns = [ url(r"^echo$", views.echo), + url(r"^echo_query$", views.echo_query), + url(r"^echo_json$", views.echo_json), url(r"^echo_form$", views.echo_form), - url(r"^echo_json$", views.echo_json), - url(r"^echo_json_or_form$", views.echo_json_or_form), url(r"^echo_use_args$", views.echo_use_args), - url(r"^echo_use_args_validated$", views.echo_use_args_validated), - url(r"^echo_ignoring_extra_data$", views.echo_ignoring_extra_data), url(r"^echo_use_kwargs$", views.echo_use_kwargs), url(r"^echo_multi$", views.echo_multi), - url(r"^echo_multi_form$", views.echo_multi_form), - url(r"^echo_multi_json$", views.echo_multi_json), url(r"^echo_many_schema$", views.echo_many_schema), url( r"^echo_use_args_with_path_param/(?P\w+)$", diff --git a/tests/apps/django_app/echo/views.py b/tests/apps/django_app/echo/views.py index 363dbc9..2dffe97 100644 --- a/tests/apps/django_app/echo/views.py +++ b/tests/apps/django_app/echo/views.py @@ -1,11 +1,11 @@ +from webargs.core import json from django.http import HttpResponse from django.views.generic import View + import marshmallow as ma - from webargs import fields from webargs.djangoparser import parser, use_args, use_kwargs -from webargs.core import json, MARSHMALLOW_VERSION_INFO - +from webargs.core import MARSHMALLOW_VERSION_INFO hello_args = {"name": fields.Str(missing="World", validate=lambda n: len(n) >= 3)} hello_multiple = {"name": fields.List(fields.Str())} @@ -18,143 +18,98 @@ strict_kwargs = {"strict": True} if MARSHMALLOW_VERSION_INFO[0] < 3 else {} hello_many_schema = HelloSchema(many=True, **strict_kwargs) -# variant which ignores unknown fields -exclude_kwargs = ( - {"strict": True} if MARSHMALLOW_VERSION_INFO[0] < 3 else {"unknown": ma.EXCLUDE} -) -hello_exclude_schema = HelloSchema(**exclude_kwargs) - def json_response(data, **kwargs): return HttpResponse(json.dumps(data), content_type="application/json", **kwargs) -def handle_view_errors(f): - def wrapped(*args, **kwargs): - try: - return f(*args, **kwargs) - except ma.ValidationError as err: - return json_response(err.messages, status=422) - except json.JSONDecodeError: - return json_response({"json": ["Invalid JSON body."]}, status=400) - - return wrapped +def echo(request): + try: + args = parser.parse(hello_args, request) + except ma.ValidationError as err: + return json_response(err.messages, status=parser.DEFAULT_VALIDATION_STATUS) + except json.JSONDecodeError: + return json_response({"json": ["Invalid JSON body."]}, status=400) + return json_response(args) -@handle_view_errors -def echo(request): - return json_response(parser.parse(hello_args, request, location="query")) +def echo_query(request): + return json_response(parser.parse(hello_args, request, locations=("query",))) -@handle_view_errors -def echo_form(request): - return json_response(parser.parse(hello_args, request, location="form")) +def echo_json(request): + return json_response(parser.parse(hello_args, request, locations=("json",))) -@handle_view_errors -def echo_json(request): - return json_response(parser.parse(hello_args, request, location="json")) +def echo_form(request): + return json_response(parser.parse(hello_args, request, locations=("form",))) -@handle_view_errors -def echo_json_or_form(request): - return json_response(parser.parse(hello_args, request, location="json_or_form")) - - -@handle_view_errors -@use_args(hello_args, location="query") +@use_args(hello_args) def echo_use_args(request, args): return json_response(args) -@handle_view_errors -@use_args( - {"value": fields.Int()}, validate=lambda args: args["value"] > 42, location="form" -) -def echo_use_args_validated(args): - return json_response(args) - - -@handle_view_errors -def echo_ignoring_extra_data(request): - return json_response(parser.parse(hello_exclude_schema, request)) - - -@handle_view_errors -@use_kwargs(hello_args, location="query") +@use_kwargs(hello_args) def echo_use_kwargs(request, name): return json_response({"name": name}) -@handle_view_errors def echo_multi(request): - return json_response(parser.parse(hello_multiple, request, location="query")) - - -@handle_view_errors -def echo_multi_form(request): - return json_response(parser.parse(hello_multiple, request, location="form")) - - -@handle_view_errors -def echo_multi_json(request): return json_response(parser.parse(hello_multiple, request)) -@handle_view_errors def echo_many_schema(request): - return json_response(parser.parse(hello_many_schema, request)) + try: + return json_response( + parser.parse(hello_many_schema, request, locations=("json",)) + ) + except ma.ValidationError as err: + return json_response(err.messages, status=parser.DEFAULT_VALIDATION_STATUS) -@handle_view_errors -@use_args({"value": fields.Int()}, location="query") +@use_args({"value": fields.Int()}) def echo_use_args_with_path_param(request, args, name): return json_response(args) -@handle_view_errors -@use_kwargs({"value": fields.Int()}, location="query") +@use_kwargs({"value": fields.Int()}) def echo_use_kwargs_with_path_param(request, value, name): return json_response({"value": value}) -@handle_view_errors def always_error(request): def always_fail(value): raise ma.ValidationError("something went wrong") argmap = {"text": fields.Str(validate=always_fail)} - return parser.parse(argmap, request) + try: + return parser.parse(argmap, request) + except ma.ValidationError as err: + return json_response(err.messages, status=parser.DEFAULT_VALIDATION_STATUS) -@handle_view_errors def echo_headers(request): - return json_response( - parser.parse(hello_exclude_schema, request, location="headers") - ) + return json_response(parser.parse(hello_args, request, locations=("headers",))) -@handle_view_errors def echo_cookie(request): - return json_response(parser.parse(hello_args, request, location="cookies")) + return json_response(parser.parse(hello_args, request, locations=("cookies",))) -@handle_view_errors def echo_file(request): args = {"myfile": fields.Field()} - result = parser.parse(args, request, location="files") + result = parser.parse(args, request, locations=("files",)) myfile = result["myfile"] content = myfile.read().decode("utf8") return json_response({"myfile": content}) -@handle_view_errors def echo_nested(request): argmap = {"name": fields.Nested({"first": fields.Str(), "last": fields.Str()})} return json_response(parser.parse(argmap, request)) -@handle_view_errors def echo_nested_many(request): argmap = { "users": fields.Nested({"id": fields.Int(), "name": fields.Str()}, many=True) @@ -163,33 +118,27 @@ class EchoCBV(View): - @handle_view_errors def get(self, request): - location_kwarg = {} if request.method == "POST" else {"location": "query"} - return json_response(parser.parse(hello_args, self.request, **location_kwarg)) + try: + args = parser.parse(hello_args, self.request) + except ma.ValidationError as err: + return json_response(err.messages, status=parser.DEFAULT_VALIDATION_STATUS) + return json_response(args) post = get class EchoUseArgsCBV(View): - @handle_view_errors - @use_args(hello_args, location="query") + @use_args(hello_args) def get(self, request, args): return json_response(args) - @handle_view_errors - @use_args(hello_args) - def post(self, request, args): - return json_response(args) + post = get class EchoUseArgsWithParamCBV(View): - @handle_view_errors - @use_args(hello_args, location="query") + @use_args(hello_args) def get(self, request, args, pid): return json_response(args) - @handle_view_errors - @use_args(hello_args) - def post(self, request, args, pid): - return json_response(args) + post = get diff --git a/tests/apps/falcon_app.py b/tests/apps/falcon_app.py index 3643019..12f5cb7 100644 --- a/tests/apps/falcon_app.py +++ b/tests/apps/falcon_app.py @@ -1,9 +1,10 @@ +from webargs.core import json + import falcon import marshmallow as ma - from webargs import fields -from webargs.core import MARSHMALLOW_VERSION_INFO, json from webargs.falconparser import parser, use_args, use_kwargs +from webargs.core import MARSHMALLOW_VERSION_INFO hello_args = {"name": fields.Str(missing="World", validate=lambda n: len(n) >= 3)} hello_multiple = {"name": fields.List(fields.Str())} @@ -16,97 +17,91 @@ strict_kwargs = {"strict": True} if MARSHMALLOW_VERSION_INFO[0] < 3 else {} hello_many_schema = HelloSchema(many=True, **strict_kwargs) -# variant which ignores unknown fields -exclude_kwargs = ( - {"strict": True} if MARSHMALLOW_VERSION_INFO[0] < 3 else {"unknown": ma.EXCLUDE} -) -hello_exclude_schema = HelloSchema(**exclude_kwargs) + +class Echo(object): + def on_get(self, req, resp): + try: + parsed = parser.parse(hello_args, req) + except json.JSONDecodeError: + resp.body = json.dumps(["Invalid JSON."]) + resp.status = falcon.HTTP_400 + else: + resp.body = json.dumps(parsed) + + on_post = on_get -class Echo: +class EchoQuery(object): def on_get(self, req, resp): - parsed = parser.parse(hello_args, req, location="query") + parsed = parser.parse(hello_args, req, locations=("query",)) resp.body = json.dumps(parsed) -class EchoForm: +class EchoJSON(object): def on_post(self, req, resp): - parsed = parser.parse(hello_args, req, location="form") + parsed = parser.parse(hello_args, req, locations=("json",)) resp.body = json.dumps(parsed) -class EchoJSON: +class EchoForm(object): def on_post(self, req, resp): - parsed = parser.parse(hello_args, req, location="json") + parsed = parser.parse(hello_args, req, locations=("form",)) resp.body = json.dumps(parsed) -class EchoJSONOrForm: - def on_post(self, req, resp): - parsed = parser.parse(hello_args, req, location="json_or_form") - resp.body = json.dumps(parsed) - - -class EchoUseArgs: - @use_args(hello_args, location="query") +class EchoUseArgs(object): + @use_args(hello_args) def on_get(self, req, resp, args): resp.body = json.dumps(args) + on_post = on_get -class EchoUseKwargs: - @use_kwargs(hello_args, location="query") + +class EchoUseKwargs(object): + @use_kwargs(hello_args) def on_get(self, req, resp, name): resp.body = json.dumps({"name": name}) + on_post = on_get -class EchoUseArgsValidated: - @use_args( - {"value": fields.Int()}, - validate=lambda args: args["value"] > 42, - location="form", - ) - def on_post(self, req, resp, args): + +class EchoUseArgsValidated(object): + @use_args({"value": fields.Int()}, validate=lambda args: args["value"] > 42) + def on_get(self, req, resp, args): resp.body = json.dumps(args) - -class EchoJSONIgnoreExtraData: - def on_post(self, req, resp): - resp.body = json.dumps(parser.parse(hello_exclude_schema, req)) + on_post = on_get -class EchoMulti: +class EchoMulti(object): def on_get(self, req, resp): - resp.body = json.dumps(parser.parse(hello_multiple, req, location="query")) + resp.body = json.dumps(parser.parse(hello_multiple, req)) + + on_post = on_get -class EchoMultiForm: - def on_post(self, req, resp): - resp.body = json.dumps(parser.parse(hello_multiple, req, location="form")) +class EchoManySchema(object): + def on_get(self, req, resp): + resp.body = json.dumps( + parser.parse(hello_many_schema, req, locations=("json",)) + ) + + on_post = on_get -class EchoMultiJSON: - def on_post(self, req, resp): - resp.body = json.dumps(parser.parse(hello_multiple, req)) - - -class EchoManySchema: - def on_post(self, req, resp): - resp.body = json.dumps(parser.parse(hello_many_schema, req)) - - -class EchoUseArgsWithPathParam: - @use_args({"value": fields.Int()}, location="query") +class EchoUseArgsWithPathParam(object): + @use_args({"value": fields.Int()}) def on_get(self, req, resp, args, name): resp.body = json.dumps(args) -class EchoUseKwargsWithPathParam: - @use_kwargs({"value": fields.Int()}, location="query") +class EchoUseKwargsWithPathParam(object): + @use_kwargs({"value": fields.Int()}) def on_get(self, req, resp, value, name): resp.body = json.dumps({"value": value}) -class AlwaysError: +class AlwaysError(object): def on_get(self, req, resp): def always_fail(value): raise ma.ValidationError("something went wrong") @@ -117,28 +112,23 @@ on_post = on_get -class EchoHeaders: +class EchoHeaders(object): def on_get(self, req, resp): - class HeaderSchema(ma.Schema): - NAME = fields.Str(missing="World") - - resp.body = json.dumps( - parser.parse(HeaderSchema(**exclude_kwargs), req, location="headers") - ) + resp.body = json.dumps(parser.parse(hello_args, req, locations=("headers",))) -class EchoCookie: +class EchoCookie(object): def on_get(self, req, resp): - resp.body = json.dumps(parser.parse(hello_args, req, location="cookies")) + resp.body = json.dumps(parser.parse(hello_args, req, locations=("cookies",))) -class EchoNested: +class EchoNested(object): def on_post(self, req, resp): args = {"name": fields.Nested({"first": fields.Str(), "last": fields.Str()})} resp.body = json.dumps(parser.parse(args, req)) -class EchoNestedMany: +class EchoNestedMany(object): def on_post(self, req, resp): args = { "users": fields.Nested( @@ -149,15 +139,15 @@ def use_args_hook(args, context_key="args", **kwargs): - def hook(req, resp, resource, params): + def hook(req, resp, params): parsed_args = parser.parse(args, req=req, **kwargs) req.context[context_key] = parsed_args return hook -@falcon.before(use_args_hook(hello_args, location="query")) -class EchoUseArgsHook: +@falcon.before(use_args_hook(hello_args)) +class EchoUseArgsHook(object): def on_get(self, req, resp): resp.body = json.dumps(req.context["args"]) @@ -165,16 +155,13 @@ def create_app(): app = falcon.API() app.add_route("/echo", Echo()) + app.add_route("/echo_query", EchoQuery()) + app.add_route("/echo_json", EchoJSON()) app.add_route("/echo_form", EchoForm()) - app.add_route("/echo_json", EchoJSON()) - app.add_route("/echo_json_or_form", EchoJSONOrForm()) app.add_route("/echo_use_args", EchoUseArgs()) app.add_route("/echo_use_kwargs", EchoUseKwargs()) app.add_route("/echo_use_args_validated", EchoUseArgsValidated()) - app.add_route("/echo_ignoring_extra_data", EchoJSONIgnoreExtraData()) app.add_route("/echo_multi", EchoMulti()) - app.add_route("/echo_multi_form", EchoMultiForm()) - app.add_route("/echo_multi_json", EchoMultiJSON()) app.add_route("/echo_many_schema", EchoManySchema()) app.add_route("/echo_use_args_with_path_param/{name}", EchoUseArgsWithPathParam()) app.add_route( diff --git a/tests/apps/flask_app.py b/tests/apps/flask_app.py index 232e715..c546dd4 100644 --- a/tests/apps/flask_app.py +++ b/tests/apps/flask_app.py @@ -1,10 +1,11 @@ +from webargs.core import json from flask import Flask, jsonify as J, Response, request from flask.views import MethodView + import marshmallow as ma - from webargs import fields from webargs.flaskparser import parser, use_args, use_kwargs -from webargs.core import json, MARSHMALLOW_VERSION_INFO +from webargs.core import MARSHMALLOW_VERSION_INFO class TestAppConfig: @@ -22,90 +23,67 @@ strict_kwargs = {"strict": True} if MARSHMALLOW_VERSION_INFO[0] < 3 else {} hello_many_schema = HelloSchema(many=True, **strict_kwargs) -# variant which ignores unknown fields -exclude_kwargs = ( - {"strict": True} if MARSHMALLOW_VERSION_INFO[0] < 3 else {"unknown": ma.EXCLUDE} -) -hello_exclude_schema = HelloSchema(**exclude_kwargs) - app = Flask(__name__) app.config.from_object(TestAppConfig) -@app.route("/echo", methods=["GET"]) +@app.route("/echo", methods=["GET", "POST"]) def echo(): - return J(parser.parse(hello_args, location="query")) + return J(parser.parse(hello_args)) + + +@app.route("/echo_query") +def echo_query(): + return J(parser.parse(hello_args, request, locations=("query",))) + + +@app.route("/echo_json", methods=["POST"]) +def echo_json(): + return J(parser.parse(hello_args, request, locations=("json",))) @app.route("/echo_form", methods=["POST"]) def echo_form(): - return J(parser.parse(hello_args, location="form")) + return J(parser.parse(hello_args, request, locations=("form",))) -@app.route("/echo_json", methods=["POST"]) -def echo_json(): - return J(parser.parse(hello_args, location="json")) - - -@app.route("/echo_json_or_form", methods=["POST"]) -def echo_json_or_form(): - return J(parser.parse(hello_args, location="json_or_form")) - - -@app.route("/echo_use_args", methods=["GET"]) -@use_args(hello_args, location="query") +@app.route("/echo_use_args", methods=["GET", "POST"]) +@use_args(hello_args) def echo_use_args(args): return J(args) -@app.route("/echo_use_args_validated", methods=["POST"]) -@use_args( - {"value": fields.Int()}, validate=lambda args: args["value"] > 42, location="form" -) +@app.route("/echo_use_args_validated", methods=["GET", "POST"]) +@use_args({"value": fields.Int()}, validate=lambda args: args["value"] > 42) def echo_use_args_validated(args): return J(args) -@app.route("/echo_ignoring_extra_data", methods=["POST"]) -def echo_json_ignore_extra_data(): - return J(parser.parse(hello_exclude_schema)) - - -@app.route("/echo_use_kwargs", methods=["GET"]) -@use_kwargs(hello_args, location="query") +@app.route("/echo_use_kwargs", methods=["GET", "POST"]) +@use_kwargs(hello_args) def echo_use_kwargs(name): return J({"name": name}) -@app.route("/echo_multi", methods=["GET"]) +@app.route("/echo_multi", methods=["GET", "POST"]) def multi(): - return J(parser.parse(hello_multiple, location="query")) - - -@app.route("/echo_multi_form", methods=["POST"]) -def multi_form(): - return J(parser.parse(hello_multiple, location="form")) - - -@app.route("/echo_multi_json", methods=["POST"]) -def multi_json(): return J(parser.parse(hello_multiple)) @app.route("/echo_many_schema", methods=["GET", "POST"]) def many_nested(): - arguments = parser.parse(hello_many_schema) + arguments = parser.parse(hello_many_schema, locations=("json",)) return Response(json.dumps(arguments), content_type="application/json") @app.route("/echo_use_args_with_path_param/") -@use_args({"value": fields.Int()}, location="query") +@use_args({"value": fields.Int()}) def echo_use_args_with_path(args, name): return J(args) @app.route("/echo_use_kwargs_with_path_param/") -@use_kwargs({"value": fields.Int()}, location="query") +@use_kwargs({"value": fields.Int()}) def echo_use_kwargs_with_path(name, value): return J({"value": value}) @@ -121,20 +99,18 @@ @app.route("/echo_headers") def echo_headers(): - # the "exclude schema" must be used in this case because WSGI headers may - # be populated with many fields not sent by the caller - return J(parser.parse(hello_exclude_schema, location="headers")) + return J(parser.parse(hello_args, locations=("headers",))) @app.route("/echo_cookie") def echo_cookie(): - return J(parser.parse(hello_args, request, location="cookies")) + return J(parser.parse(hello_args, request, locations=("cookies",))) @app.route("/echo_file", methods=["POST"]) def echo_file(): args = {"myfile": fields.Field()} - result = parser.parse(args, location="files") + result = parser.parse(args, locations=("files",)) fp = result["myfile"] content = fp.read().decode("utf8") return J({"myfile": content}) @@ -142,11 +118,11 @@ @app.route("/echo_view_arg/") def echo_view_arg(view_arg): - return J(parser.parse({"view_arg": fields.Int()}, location="view_args")) + return J(parser.parse({"view_arg": fields.Int()}, locations=("view_args",))) @app.route("/echo_view_arg_use_args/") -@use_args({"view_arg": fields.Int()}, location="view_args") +@use_args({"view_arg": fields.Int(location="view_args")}) def echo_view_arg_with_use_args(args, **kwargs): return J(args) @@ -211,16 +187,4 @@ def handle_error(err): if err.code == 422: assert isinstance(err.data["schema"], ma.Schema) - - if MARSHMALLOW_VERSION_INFO[0] >= 3: - return J(err.data["messages"]), err.code - - # on marshmallow2, validation errors for nested schemas can fail to encode: - # https://github.com/marshmallow-code/marshmallow/issues/493 - # to workaround this, convert integer keys to strings - def tweak_data(value): - if not isinstance(value, dict): - return value - return {str(k): v for k, v in value.items()} - - return J({k: tweak_data(v) for k, v in err.data["messages"].items()}), err.code + return J(err.data["messages"]), err.code diff --git a/tests/apps/pyramid_app.py b/tests/apps/pyramid_app.py index 6404b46..f4fa0e5 100644 --- a/tests/apps/pyramid_app.py +++ b/tests/apps/pyramid_app.py @@ -1,10 +1,12 @@ +from webargs.core import json + from pyramid.config import Configurator from pyramid.httpexceptions import HTTPBadRequest import marshmallow as ma from webargs import fields from webargs.pyramidparser import parser, use_args, use_kwargs -from webargs.core import json, MARSHMALLOW_VERSION_INFO +from webargs.core import MARSHMALLOW_VERSION_INFO hello_args = {"name": fields.Str(missing="World", validate=lambda n: len(n) >= 3)} hello_multiple = {"name": fields.List(fields.Str())} @@ -17,44 +19,10 @@ strict_kwargs = {"strict": True} if MARSHMALLOW_VERSION_INFO[0] < 3 else {} hello_many_schema = HelloSchema(many=True, **strict_kwargs) -# variant which ignores unknown fields -exclude_kwargs = ( - {"strict": True} if MARSHMALLOW_VERSION_INFO[0] < 3 else {"unknown": ma.EXCLUDE} -) -hello_exclude_schema = HelloSchema(**exclude_kwargs) - def echo(request): - return parser.parse(hello_args, request, location="query") - - -def echo_form(request): - return parser.parse(hello_args, request, location="form") - - -def echo_json(request): try: - return parser.parse(hello_args, request, location="json") - except json.JSONDecodeError: - error = HTTPBadRequest() - error.body = json.dumps(["Invalid JSON."]).encode("utf-8") - error.content_type = "application/json" - raise error - - -def echo_json_or_form(request): - try: - return parser.parse(hello_args, request, location="json_or_form") - except json.JSONDecodeError: - error = HTTPBadRequest() - error.body = json.dumps(["Invalid JSON."]).encode("utf-8") - error.content_type = "application/json" - raise error - - -def echo_json_ignore_extra_data(request): - try: - return parser.parse(hello_exclude_schema, request) + return parser.parse(hello_args, request) except json.JSONDecodeError: error = HTTPBadRequest() error.body = json.dumps(["Invalid JSON."]).encode("utf-8") @@ -63,48 +31,46 @@ def echo_query(request): - return parser.parse(hello_args, request, location="query") + return parser.parse(hello_args, request, locations=("query",)) -@use_args(hello_args, location="query") +def echo_json(request): + return parser.parse(hello_args, request, locations=("json",)) + + +def echo_form(request): + return parser.parse(hello_args, request, locations=("form",)) + + +@use_args(hello_args) def echo_use_args(request, args): return args -@use_args( - {"value": fields.Int()}, validate=lambda args: args["value"] > 42, location="form" -) +@use_args({"value": fields.Int()}, validate=lambda args: args["value"] > 42) def echo_use_args_validated(request, args): return args -@use_kwargs(hello_args, location="query") +@use_kwargs(hello_args) def echo_use_kwargs(request, name): return {"name": name} def echo_multi(request): - return parser.parse(hello_multiple, request, location="query") - - -def echo_multi_form(request): - return parser.parse(hello_multiple, request, location="form") - - -def echo_multi_json(request): return parser.parse(hello_multiple, request) def echo_many_schema(request): - return parser.parse(hello_many_schema, request) + return parser.parse(hello_many_schema, request, locations=("json",)) -@use_args({"value": fields.Int()}, location="query") +@use_args({"value": fields.Int()}) def echo_use_args_with_path_param(request, args): return args -@use_kwargs({"value": fields.Int()}, location="query") +@use_kwargs({"value": fields.Int()}) def echo_use_kwargs_with_path_param(request, value): return {"value": value} @@ -118,16 +84,16 @@ def echo_headers(request): - return parser.parse(hello_exclude_schema, request, location="headers") + return parser.parse(hello_args, request, locations=("headers",)) def echo_cookie(request): - return parser.parse(hello_args, request, location="cookies") + return parser.parse(hello_args, request, locations=("cookies",)) def echo_file(request): args = {"myfile": fields.Field()} - result = parser.parse(args, request, location="files") + result = parser.parse(args, request, locations=("files",)) myfile = result["myfile"] content = myfile.file.read().decode("utf8") return {"myfile": content} @@ -146,14 +112,14 @@ def echo_matchdict(request): - return parser.parse({"mymatch": fields.Int()}, request, location="matchdict") + return parser.parse({"mymatch": fields.Int()}, request, locations=("matchdict",)) -class EchoCallable: +class EchoCallable(object): def __init__(self, request): self.request = request - @use_args({"value": fields.Int()}, location="query") + @use_args({"value": fields.Int()}) def __call__(self, args): return args @@ -169,17 +135,13 @@ config = Configurator() add_route(config, "/echo", echo) + add_route(config, "/echo_query", echo_query) + add_route(config, "/echo_json", echo_json) add_route(config, "/echo_form", echo_form) - add_route(config, "/echo_json", echo_json) - add_route(config, "/echo_json_or_form", echo_json_or_form) - add_route(config, "/echo_query", echo_query) - add_route(config, "/echo_ignoring_extra_data", echo_json_ignore_extra_data) add_route(config, "/echo_use_args", echo_use_args) add_route(config, "/echo_use_args_validated", echo_use_args_validated) add_route(config, "/echo_use_kwargs", echo_use_kwargs) add_route(config, "/echo_multi", echo_multi) - add_route(config, "/echo_multi_form", echo_multi_form) - add_route(config, "/echo_multi_json", echo_multi_json) add_route(config, "/echo_many_schema", echo_many_schema) add_route( config, "/echo_use_args_with_path_param/{name}", echo_use_args_with_path_param diff --git a/tests/compat.py b/tests/compat.py new file mode 100644 index 0000000..c0c7545 --- /dev/null +++ b/tests/compat.py @@ -0,0 +1,16 @@ +# -*- coding: utf-8 -*- +# flake8: noqa +import sys + +PY2 = int(sys.version[0]) == 2 + +if PY2: + text_type = unicode + binary_type = str + string_types = (str, unicode) + basestring = basestring +else: + text_type = str + binary_type = bytes + string_types = (str,) + basestring = (str, bytes) diff --git a/tests/test_core.py b/tests/test_core.py index a9a32e9..be8038b 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -1,4 +1,6 @@ +# -*- coding: utf-8 -*- import itertools +import mock import datetime import pytest @@ -7,22 +9,15 @@ from django.utils.datastructures import MultiValueDict as DjMultiDict from bottle import MultiDict as BotMultiDict -from webargs import fields, ValidationError +from webargs import fields, missing, ValidationError from webargs.core import ( Parser, + get_value, dict2schema, is_json, get_mimetype, MARSHMALLOW_VERSION_INFO, ) -from webargs.multidictproxy import MultiDictProxy - -try: - # Python 3.5 - import mock -except ImportError: - # Python 3.6+ - from unittest import mock strict_kwargs = {"strict": True} if MARSHMALLOW_VERSION_INFO[0] < 3 else {} @@ -32,20 +27,20 @@ def __init__(self, status_code, headers): self.status_code = status_code self.headers = headers - super().__init__(self, "HTTP Error occurred") + super(MockHTTPError, self).__init__(self, "HTTP Error occurred") class MockRequestParser(Parser): """A minimal parser implementation that parses mock requests.""" - def load_querystring(self, req, schema): - return MultiDictProxy(req.query, schema) - - def load_json(self, req, schema): - return req.json - - def load_cookies(self, req, schema): - return req.cookies + def parse_querystring(self, req, name, field): + return get_value(req.query, name, field) + + def parse_json(self, req, name, field): + return get_value(req.json, name, field) + + def parse_cookies(self, req, name, field): + return get_value(req.cookies, name, field) @pytest.yield_fixture(scope="function") @@ -64,73 +59,65 @@ # Parser tests -@mock.patch("webargs.core.Parser.load_json") -def test_load_json_called_by_parse_default(load_json, web_request): - schema = dict2schema({"foo": fields.Field()})() - load_json.return_value = {"foo": 1} - p = Parser() - p.parse(schema, web_request) - load_json.assert_called_with(web_request, schema) - - -@pytest.mark.parametrize( - "location", ["querystring", "form", "headers", "cookies", "files"] -) -def test_load_nondefault_called_by_parse_with_location(location, web_request): - with mock.patch( - "webargs.core.Parser.load_{}".format(location) - ) as mock_loadfunc, mock.patch("webargs.core.Parser.load_json") as load_json: - mock_loadfunc.return_value = {} - load_json.return_value = {} - p = Parser() - - # ensure that without location=..., the loader is not called (json is - # called) - p.parse({"foo": fields.Field()}, web_request) - assert mock_loadfunc.call_count == 0 - assert load_json.call_count == 1 - - # but when location=... is given, the loader *is* called and json is - # not called - p.parse({"foo": fields.Field()}, web_request, location=location) - assert mock_loadfunc.call_count == 1 - # it was already 1, should not go up - assert load_json.call_count == 1 - - -def test_parse(parser, web_request): - web_request.json = {"username": 42, "password": 42} +@mock.patch("webargs.core.Parser.parse_json") +def test_parse_json_called_by_parse_arg(parse_json, web_request): + field = fields.Field() + p = Parser() + p.parse_arg("foo", field, web_request) + parse_json.assert_called_with(web_request, "foo", field) + + +@mock.patch("webargs.core.Parser.parse_querystring") +def test_parse_querystring_called_by_parse_arg(parse_querystring, web_request): + field = fields.Field() + p = Parser() + p.parse_arg("foo", field, web_request) + assert parse_querystring.called_once() + + +@mock.patch("webargs.core.Parser.parse_form") +def test_parse_form_called_by_parse_arg(parse_form, web_request): + field = fields.Field() + p = Parser() + p.parse_arg("foo", field, web_request) + assert parse_form.called_once() + + +@mock.patch("webargs.core.Parser.parse_json") +def test_parse_json_not_called_when_json_not_a_location(parse_json, web_request): + field = fields.Field() + p = Parser() + p.parse_arg("foo", field, web_request, locations=("form", "querystring")) + assert parse_json.call_count == 0 + + +@mock.patch("webargs.core.Parser.parse_headers") +def test_parse_headers_called_when_headers_is_a_location(parse_headers, web_request): + field = fields.Field() + p = Parser() + p.parse_arg("foo", field, web_request) + assert parse_headers.call_count == 0 + p.parse_arg("foo", field, web_request, locations=("headers",)) + parse_headers.assert_called() + + +@mock.patch("webargs.core.Parser.parse_cookies") +def test_parse_cookies_called_when_cookies_is_a_location(parse_cookies, web_request): + field = fields.Field() + p = Parser() + p.parse_arg("foo", field, web_request) + assert parse_cookies.call_count == 0 + p.parse_arg("foo", field, web_request, locations=("cookies",)) + parse_cookies.assert_called() + + +@mock.patch("webargs.core.Parser.parse_json") +def test_parse(parse_json, web_request): + parse_json.return_value = 42 argmap = {"username": fields.Field(), "password": fields.Field()} - ret = parser.parse(argmap, web_request) + p = Parser() + ret = p.parse(argmap, web_request) assert {"username": 42, "password": 42} == ret - - -@pytest.mark.skipif( - MARSHMALLOW_VERSION_INFO[0] < 3, reason="unknown=... added in marshmallow3" -) -def test_parse_with_unknown_behavior_specified(parser, web_request): - # This is new in webargs 6.x ; it's the way you can "get back" the behavior - # of webargs 5.x in which extra args are ignored - from marshmallow import EXCLUDE, INCLUDE, RAISE - - web_request.json = {"username": 42, "password": 42, "fjords": 42} - - class CustomSchema(Schema): - username = fields.Field() - password = fields.Field() - - # with no unknown setting or unknown=RAISE, it blows up - with pytest.raises(ValidationError, match="Unknown field."): - parser.parse(CustomSchema(), web_request) - with pytest.raises(ValidationError, match="Unknown field."): - parser.parse(CustomSchema(unknown=RAISE), web_request) - - # with unknown=EXCLUDE the data is omitted - ret = parser.parse(CustomSchema(unknown=EXCLUDE), web_request) - assert {"username": 42, "password": 42} == ret - # with unknown=INCLUDE it is added even though it isn't part of the schema - ret = parser.parse(CustomSchema(unknown=INCLUDE), web_request) - assert {"username": 42, "password": 42, "fjords": 42} == ret def test_parse_required_arg_raises_validation_error(parser, web_request): @@ -154,10 +141,13 @@ assert result == {"first": "Steve", "last": None} -def test_parse_required_arg(parser, web_request): - web_request.json = {"foo": 42} - result = parser.parse({"foo": fields.Field(required=True)}, web_request) - assert result == {"foo": 42} +@mock.patch("webargs.core.Parser.parse_json") +def test_parse_required_arg(parse_json, web_request): + arg = fields.Field(required=True) + parse_json.return_value = 42 + p = Parser() + result = p.parse_arg("foo", arg, web_request, locations=("json",)) + assert result == 42 def test_parse_required_list(parser, web_request): @@ -165,9 +155,7 @@ args = {"foo": fields.List(fields.Field(), required=True)} with pytest.raises(ValidationError) as excinfo: parser.parse(args, web_request) - assert ( - excinfo.value.messages["json"]["foo"][0] == "Missing data for required field." - ) + assert excinfo.value.messages["foo"][0] == "Missing data for required field." # Regression test for https://github.com/marshmallow-code/webargs/issues/107 @@ -182,7 +170,7 @@ args = {"foo": fields.List(fields.Field(), allow_none=False)} with pytest.raises(ValidationError) as excinfo: parser.parse(args, web_request) - assert excinfo.value.messages["json"]["foo"][0] == "Field may not be null." + assert excinfo.value.messages["foo"][0] == "Field may not be null." def test_parse_empty_list(parser, web_request): @@ -197,21 +185,21 @@ assert parser.parse(args, web_request) == {} -def test_default_location(): - assert Parser.DEFAULT_LOCATION == "json" +def test_default_locations(): + assert set(Parser.DEFAULT_LOCATIONS) == set(["json", "querystring", "form"]) def test_missing_with_default(parser, web_request): web_request.json = {} args = {"val": fields.Field(missing="pizza")} - result = parser.parse(args, web_request) + result = parser.parse(args, web_request, locations=("json",)) assert result["val"] == "pizza" def test_default_can_be_none(parser, web_request): web_request.json = {} args = {"val": fields.Field(missing=None, allow_none=True)} - result = parser.parse(args, web_request) + result = parser.parse(args, web_request, locations=("json",)) assert result["val"] is None @@ -222,130 +210,141 @@ "p": fields.Int( missing=1, validate=lambda p: p > 0, - error="La page demandée n'existe pas", + error=u"La page demandée n'existe pas", location="querystring", ) } assert parser.parse(args, web_request) == {"p": 1} -def test_value_error_raised_if_parse_called_with_invalid_location(parser, web_request): +def test_value_error_raised_if_parse_arg_called_with_invalid_location(web_request): field = fields.Field() - with pytest.raises(ValueError, match="Invalid location argument: invalidlocation"): - parser.parse({"foo": field}, web_request, location="invalidlocation") + p = Parser() + with pytest.raises(ValueError) as excinfo: + p.parse_arg("foo", field, web_request, locations=("invalidlocation", "headers")) + msg = "Invalid locations arguments: {0}".format(["invalidlocation"]) + assert msg in str(excinfo.value) + + +def test_value_error_raised_if_invalid_location_on_field(web_request, parser): + with pytest.raises(ValueError) as excinfo: + parser.parse({"foo": fields.Field(location="invalidlocation")}, web_request) + msg = "Invalid locations arguments: {0}".format(["invalidlocation"]) + assert msg in str(excinfo.value) @mock.patch("webargs.core.Parser.handle_error") -def test_handle_error_called_when_parsing_raises_error(handle_error, web_request): - def always_fail(*args, **kwargs): - raise ValidationError("error occurred") - - p = Parser() - assert handle_error.call_count == 0 - p.parse({"foo": fields.Field()}, web_request, validate=always_fail) - assert handle_error.call_count == 1 - p.parse({"foo": fields.Field()}, web_request, validate=always_fail) +@mock.patch("webargs.core.Parser.parse_json") +def test_handle_error_called_when_parsing_raises_error( + parse_json, handle_error, web_request +): + val_err = ValidationError("error occurred") + parse_json.side_effect = val_err + p = Parser() + p.parse({"foo": fields.Field()}, web_request, locations=("json",)) + handle_error.assert_called() + parse_json.side_effect = ValidationError("another exception") + p.parse({"foo": fields.Field()}, web_request, locations=("json",)) assert handle_error.call_count == 2 def test_handle_error_reraises_errors(web_request): p = Parser() with pytest.raises(ValidationError): - p.handle_error( - ValidationError("error raised"), - web_request, - Schema(), - error_status_code=422, - error_headers={}, - ) - - -@mock.patch("webargs.core.Parser.load_headers") -def test_location_as_init_argument(load_headers, web_request): - p = Parser(location="headers") - load_headers.return_value = {} + p.handle_error(ValidationError("error raised"), web_request, Schema()) + + +@mock.patch("webargs.core.Parser.parse_headers") +def test_locations_as_init_arguments(parse_headers, web_request): + p = Parser(locations=("headers",)) p.parse({"foo": fields.Field()}, web_request) - assert load_headers.called - - -def test_custom_error_handler(web_request): + assert parse_headers.called + + +@mock.patch("webargs.core.Parser.parse_files") +def test_parse_files(parse_files, web_request): + p = Parser() + p.parse({"foo": fields.Field()}, web_request, locations=("files",)) + assert parse_files.called + + +@mock.patch("webargs.core.Parser.parse_json") +def test_custom_error_handler(parse_json, web_request): class CustomError(Exception): pass - def error_handler(error, req, schema, *, error_status_code, error_headers): + def error_handler(error, req, schema, status_code, headers): assert isinstance(schema, Schema) raise CustomError(error) - def failing_validate_func(args): - raise ValidationError("parsing failed") - - class MySchema(Schema): - foo = fields.Int() - - myschema = MySchema(**strict_kwargs) - web_request.json = {"foo": "hello world"} - + parse_json.side_effect = ValidationError("parse_json failed") p = Parser(error_handler=error_handler) with pytest.raises(CustomError): - p.parse(myschema, web_request, validate=failing_validate_func) - - -def test_custom_error_handler_decorator(web_request): + p.parse({"foo": fields.Field()}, web_request) + + +@mock.patch("webargs.core.Parser.parse_json") +def test_custom_error_handler_decorator(parse_json, web_request): class CustomError(Exception): pass - mock_schema = mock.Mock(spec=Schema) - mock_schema.strict = True - mock_schema.load.side_effect = ValidationError("parsing json failed") + parse_json.side_effect = ValidationError("parse_json failed") + parser = Parser() @parser.error_handler - def handle_error(error, req, schema, *, error_status_code, error_headers): + def handle_error(error, req, schema, status_code, headers): assert isinstance(schema, Schema) raise CustomError(error) with pytest.raises(CustomError): - parser.parse(mock_schema, web_request) - - -def test_custom_location_loader(web_request): + parser.parse({"foo": fields.Field()}, web_request) + + +def test_custom_location_handler(web_request): web_request.data = {"foo": 42} parser = Parser() - @parser.location_loader("data") - def load_data(req, schema): - return req.data - - result = parser.parse({"foo": fields.Int()}, web_request, location="data") + @parser.location_handler("data") + def parse_data(req, name, arg): + return req.data.get(name, missing) + + result = parser.parse({"foo": fields.Int()}, web_request, locations=("data",)) assert result["foo"] == 42 -def test_custom_location_loader_with_data_key(web_request): +def test_custom_location_handler_with_data_key(web_request): web_request.data = {"X-Foo": 42} parser = Parser() - @parser.location_loader("data") - def load_data(req, schema): - return req.data + @parser.location_handler("data") + def parse_data(req, name, arg): + return req.data.get(name, missing) data_key_kwarg = { "load_from" if (MARSHMALLOW_VERSION_INFO[0] < 3) else "data_key": "X-Foo" } result = parser.parse( - {"x_foo": fields.Int(**data_key_kwarg)}, web_request, location="data" + {"x_foo": fields.Int(**data_key_kwarg)}, web_request, locations=("data",) ) assert result["x_foo"] == 42 -def test_full_input_validation(parser, web_request): +def test_full_input_validation(web_request): web_request.json = {"foo": 41, "bar": 42} + parser = MockRequestParser() args = {"foo": fields.Int(), "bar": fields.Int()} with pytest.raises(ValidationError): # Test that `validate` receives dictionary of args - parser.parse(args, web_request, validate=lambda args: args["foo"] > args["bar"]) + parser.parse( + args, + web_request, + locations=("json",), + validate=lambda args: args["foo"] > args["bar"], + ) def test_full_input_validation_with_multiple_validators(web_request, parser): @@ -361,29 +360,31 @@ web_request.json = {"a": 2, "b": 1} validators = [validate1, validate2] with pytest.raises(ValidationError, match="b must be > a"): - parser.parse(args, web_request, validate=validators) + parser.parse(args, web_request, locations=("json",), validate=validators) web_request.json = {"a": 1, "b": 2} with pytest.raises(ValidationError, match="a must be > b"): - parser.parse(args, web_request, validate=validators) - - -def test_required_with_custom_error(parser, web_request): + parser.parse(args, web_request, locations=("json",), validate=validators) + + +def test_required_with_custom_error(web_request): web_request.json = {} + parser = MockRequestParser() args = { "foo": fields.Str(required=True, error_messages={"required": "We need foo"}) } with pytest.raises(ValidationError) as excinfo: # Test that `validate` receives dictionary of args - parser.parse(args, web_request) - - assert "We need foo" in excinfo.value.messages["json"]["foo"] + parser.parse(args, web_request, locations=("json",)) + + assert "We need foo" in excinfo.value.messages["foo"] if MARSHMALLOW_VERSION_INFO[0] < 3: assert "foo" in excinfo.value.field_names -def test_required_with_custom_error_and_validation_error(parser, web_request): +def test_required_with_custom_error_and_validation_error(web_request): web_request.json = {"foo": ""} + parser = MockRequestParser() args = { "foo": fields.Str( required="We need foo", @@ -393,7 +394,7 @@ } with pytest.raises(ValidationError) as excinfo: # Test that `validate` receives dictionary of args - parser.parse(args, web_request) + parser.parse(args, web_request, locations=("json",)) assert "foo required length is 3" in excinfo.value.args[0]["foo"] if MARSHMALLOW_VERSION_INFO[0] < 3: @@ -404,19 +405,27 @@ def validate(val): return False - text = "øœ∑∆∑" + text = u"øœ∑∆∑" web_request.json = {"text": text} parser = MockRequestParser() args = {"text": fields.Str()} with pytest.raises(ValidationError) as excinfo: - parser.parse(args, web_request, validate=validate) - assert excinfo.value.messages == {"json": ["Invalid value."]} + parser.parse(args, web_request, locations=("json",), validate=validate) + assert excinfo.value.messages == ["Invalid value."] def test_invalid_argument_for_validate(web_request, parser): with pytest.raises(ValueError) as excinfo: parser.parse({}, web_request, validate="notcallable") assert "not a callable or list of callables." in excinfo.value.args[0] + + +def test_get_value_basic(): + assert get_value({"foo": 42}, "foo", False) == 42 + assert get_value({"foo": 42}, "bar", False) is missing + assert get_value({"foos": ["a", "b"]}, "foos", True) == ["a", "b"] + # https://github.com/marshmallow-code/webargs/pull/30 + assert get_value({"foos": ["a", "b"]}, "bar", True) is missing def create_bottle_multi_dict(): @@ -434,24 +443,9 @@ @pytest.mark.parametrize("input_dict", multidicts) -def test_multidict_proxy(input_dict): - class ListSchema(Schema): - foos = fields.List(fields.Str()) - - class StrSchema(Schema): - foos = fields.Str() - - # this MultiDictProxy is aware that "foos" is a list field and will - # therefore produce a list with __getitem__ - list_wrapped_multidict = MultiDictProxy(input_dict, ListSchema()) - - # this MultiDictProxy is under the impression that "foos" is just a string - # and it should return "a" or "b" - # the decision between "a" and "b" in this case belongs to the framework - str_wrapped_multidict = MultiDictProxy(input_dict, StrSchema()) - - assert list_wrapped_multidict["foos"] == ["a", "b"] - assert str_wrapped_multidict["foos"] in ("a", "b") +def test_get_value_multidict(input_dict): + field = fields.List(fields.Str()) + assert get_value(input_dict, "foos", field) == ["a", "b"] def test_parse_with_data_key(web_request): @@ -462,7 +456,7 @@ "load_from" if (MARSHMALLOW_VERSION_INFO[0] < 3) else "data_key": "Content-Type" } args = {"content_type": fields.Field(**data_key_kwargs)} - parsed = parser.parse(args, web_request) + parsed = parser.parse(args, web_request, locations=("json",)) assert parsed == {"content_type": "application/json"} @@ -476,7 +470,7 @@ parser = MockRequestParser() args = {"content_type": fields.Field(load_from="Content-Type")} - parsed = parser.parse(args, web_request) + parsed = parser.parse(args, web_request, locations=("json",)) assert parsed == {"content_type": "application/json"} @@ -489,10 +483,9 @@ } args = {"content_type": fields.Str(**data_key_kwargs)} with pytest.raises(ValidationError) as excinfo: - parser.parse(args, web_request) - assert "json" in excinfo.value.messages - assert "Content-Type" in excinfo.value.messages["json"] - assert excinfo.value.messages["json"]["Content-Type"] == ["Not a valid string."] + parser.parse(args, web_request, locations=("json",)) + assert "Content-Type" in excinfo.value.messages + assert excinfo.value.messages["Content-Type"] == ["Not a valid string."] def test_parse_nested_with_data_key(web_request): @@ -503,7 +496,7 @@ } args = {"nested_arg": fields.Nested({"right": fields.Field(**data_key_kwarg)})} - parsed = parser.parse(args, web_request) + parsed = parser.parse(args, web_request, locations=("json",)) assert parsed == {"nested_arg": {"right": "OK"}} @@ -520,7 +513,7 @@ ) } - parsed = parser.parse(args, web_request) + parsed = parser.parse(args, web_request, locations=("json",)) assert parsed == {"nested_arg": {"found": None}} @@ -530,7 +523,7 @@ web_request.json = {"nested_arg": {}} args = {"nested_arg": fields.Nested({"miss": fields.Field(missing="")})} - parsed = parser.parse(args, web_request) + parsed = parser.parse(args, web_request, locations=("json",)) assert parsed == {"nested_arg": {"miss": ""}} @@ -561,8 +554,8 @@ web_request.json = {"username": "foo"} web_request.query = {"page": 42} - @parser.use_args(query_args, web_request, location="query") - @parser.use_args(json_args, web_request) + @parser.use_args(query_args, web_request, locations=("query",)) + @parser.use_args(json_args, web_request, locations=("json",)) def viewfunc(query_parsed, json_parsed): return {"json": json_parsed, "query": query_parsed} @@ -577,8 +570,8 @@ web_request.json = {"username": "foo"} web_request.query = {"page": 42} - @parser.use_kwargs(query_args, web_request, location="query") - @parser.use_kwargs(json_args, web_request) + @parser.use_kwargs(query_args, web_request, locations=("query",)) + @parser.use_kwargs(json_args, web_request, locations=("json",)) def viewfunc(page, username): return {"json": {"username": username}, "query": {"page": page}} @@ -599,26 +592,24 @@ def test_list_allowed_missing(web_request, parser): args = {"name": fields.List(fields.Str())} - web_request.json = {} + web_request.json = {"fakedata": True} result = parser.parse(args, web_request) assert result == {} def test_int_list_allowed_missing(web_request, parser): args = {"name": fields.List(fields.Int())} - web_request.json = {} + web_request.json = {"fakedata": True} result = parser.parse(args, web_request) assert result == {} def test_multiple_arg_required_with_int_conversion(web_request, parser): args = {"ids": fields.List(fields.Int(), required=True)} - web_request.json = {} + web_request.json = {"fakedata": True} with pytest.raises(ValidationError) as excinfo: parser.parse(args, web_request) - assert excinfo.value.messages == { - "json": {"ids": ["Missing data for required field."]} - } + assert excinfo.value.messages == {"ids": ["Missing data for required field."]} def test_parse_with_callable(web_request, parser): @@ -756,22 +747,10 @@ assert "strict=True" in str(warning.message) def test_use_kwargs_stacked(self, web_request, parser): - if MARSHMALLOW_VERSION_INFO[0] >= 3: - from marshmallow import EXCLUDE - - class PageSchema(Schema): - page = fields.Int() - - pageschema = PageSchema(unknown=EXCLUDE) - userschema = self.UserSchema(unknown=EXCLUDE) - else: - pageschema = {"page": fields.Int()} - userschema = self.UserSchema(**strict_kwargs) - web_request.json = {"email": "foo@bar.com", "password": "bar", "page": 42} - @parser.use_kwargs(pageschema, web_request) - @parser.use_kwargs(userschema, web_request) + @parser.use_kwargs({"page": fields.Int()}, web_request) + @parser.use_kwargs(self.UserSchema(**strict_kwargs), web_request) def viewfunc(email, password, page): return {"email": email, "password": password, "page": page} @@ -795,18 +774,18 @@ return True web_request.json = {"name": "Eric Cartman"} - res = parser.parse(UserSchema, web_request) + res = parser.parse(UserSchema, web_request, locations=("json",)) assert res == {"name": "Eric Cartman"} -def test_use_args_with_custom_location_in_parser(web_request, parser): +def test_use_args_with_custom_locations_in_parser(web_request, parser): custom_args = {"foo": fields.Str()} web_request.json = {} - parser.location = "custom" - - @parser.location_loader("custom") - def load_custom(schema, req): - return {"foo": "bar"} + parser.locations = ("custom",) + + @parser.location_handler("custom") + def parse_custom(req, name, arg): + return "bar" @parser.use_args(custom_args, web_request) def viewfunc(args): @@ -848,59 +827,32 @@ dumped = schema.dump(parsed) data = dumped.data if MARSHMALLOW_VERSION_INFO[0] < 3 else dumped + assert data["ids"] == [1, 2, 3] + + +def test_delimited_list_as_string(web_request, parser): + web_request.json = {"ids": "1,2,3"} + schema_cls = dict2schema( + {"ids": fields.DelimitedList(fields.Int(), as_string=True)} + ) + schema = schema_cls() + + parsed = parser.parse(schema, web_request) + assert parsed["ids"] == [1, 2, 3] + + dumped = schema.dump(parsed) + data = dumped.data if MARSHMALLOW_VERSION_INFO[0] < 3 else dumped assert data["ids"] == "1,2,3" -@pytest.mark.skipif( - MARSHMALLOW_VERSION_INFO[0] < 3, reason="fields.Tuple added in marshmallow3" -) -def test_delimited_tuple_default_delimiter(web_request, parser): - """ - Test load and dump from DelimitedTuple, including the use of a datetime - type (similar to a DelimitedList test below) which confirms that we aren't - relying on __str__, but are properly de/serializing the included fields - """ - web_request.json = {"ids": "1,2,2020-05-04"} +def test_delimited_list_as_string_v2(web_request, parser): + web_request.json = {"dates": "2018-11-01,2018-11-02"} schema_cls = dict2schema( { - "ids": fields.DelimitedTuple( - (fields.Int, fields.Int, fields.DateTime(format="%Y-%m-%d")) + "dates": fields.DelimitedList( + fields.DateTime(format="%Y-%m-%d"), as_string=True ) } - ) - schema = schema_cls() - - parsed = parser.parse(schema, web_request) - assert parsed["ids"] == (1, 2, datetime.datetime(2020, 5, 4)) - - data = schema.dump(parsed) - assert data["ids"] == "1,2,2020-05-04" - - -@pytest.mark.skipif( - MARSHMALLOW_VERSION_INFO[0] < 3, reason="fields.Tuple added in marshmallow3" -) -def test_delimited_tuple_incorrect_arity(web_request, parser): - web_request.json = {"ids": "1,2"} - schema_cls = dict2schema( - {"ids": fields.DelimitedTuple((fields.Int, fields.Int, fields.Int))} - ) - schema = schema_cls() - - with pytest.raises(ValidationError): - parser.parse(schema, web_request) - - -def test_delimited_list_with_datetime(web_request, parser): - """ - Test that DelimitedList(DateTime(format=...)) correctly parses and dumps - dates to and from strings -- indicates that we're doing proper - serialization of values in dump() and not just relying on __str__ producing - correct results - """ - web_request.json = {"dates": "2018-11-01,2018-11-02"} - schema_cls = dict2schema( - {"dates": fields.DelimitedList(fields.DateTime(format="%Y-%m-%d"))} ) schema = schema_cls() @@ -923,55 +875,14 @@ parsed = parser.parse(schema, web_request) assert parsed["ids"] == [1, 2, 3] - dumped = schema.dump(parsed) - data = dumped.data if MARSHMALLOW_VERSION_INFO[0] < 3 else dumped - assert data["ids"] == "1|2|3" - - -@pytest.mark.skipif( - MARSHMALLOW_VERSION_INFO[0] < 3, reason="fields.Tuple added in marshmallow3" -) -def test_delimited_tuple_custom_delimiter(web_request, parser): - web_request.json = {"ids": "1|2"} - schema_cls = dict2schema( - {"ids": fields.DelimitedTuple((fields.Int, fields.Int), delimiter="|")} - ) - schema = schema_cls() - - parsed = parser.parse(schema, web_request) - assert parsed["ids"] == (1, 2) - - data = schema.dump(parsed) - assert data["ids"] == "1|2" - - -def test_delimited_list_load_list_errors(web_request, parser): + +def test_delimited_list_load_list(web_request, parser): web_request.json = {"ids": [1, 2, 3]} schema_cls = dict2schema({"ids": fields.DelimitedList(fields.Int())}) schema = schema_cls() - with pytest.raises(ValidationError) as excinfo: - parser.parse(schema, web_request) - exc = excinfo.value - assert isinstance(exc, ValidationError) - errors = exc.args[0] - assert errors["ids"] == ["Not a valid delimited list."] - - -@pytest.mark.skipif( - MARSHMALLOW_VERSION_INFO[0] < 3, reason="fields.Tuple added in marshmallow3" -) -def test_delimited_tuple_load_list_errors(web_request, parser): - web_request.json = {"ids": [1, 2]} - schema_cls = dict2schema({"ids": fields.DelimitedTuple((fields.Int, fields.Int))}) - schema = schema_cls() - - with pytest.raises(ValidationError) as excinfo: - parser.parse(schema, web_request) - exc = excinfo.value - assert isinstance(exc, ValidationError) - errors = exc.args[0] - assert errors["ids"] == ["Not a valid delimited tuple."] + parsed = parser.parse(schema, web_request) + assert parsed["ids"] == [1, 2, 3] # Regresion test for https://github.com/marshmallow-code/webargs/issues/149 @@ -982,20 +893,7 @@ with pytest.raises(ValidationError) as excinfo: parser.parse(schema, web_request) - assert excinfo.value.messages == {"json": {"ids": ["Not a valid delimited list."]}} - - -@pytest.mark.skipif( - MARSHMALLOW_VERSION_INFO[0] < 3, reason="fields.Tuple added in marshmallow3" -) -def test_delimited_tuple_passed_invalid_type(web_request, parser): - web_request.json = {"ids": 1} - schema_cls = dict2schema({"ids": fields.DelimitedTuple((fields.Int,))}) - schema = schema_cls() - - with pytest.raises(ValidationError) as excinfo: - parser.parse(schema, web_request) - assert excinfo.value.messages == {"json": {"ids": ["Not a valid delimited tuple."]}} + assert excinfo.value.messages == {"ids": ["Not a valid list."]} def test_missing_list_argument_not_in_parsed_result(web_request, parser): @@ -1013,6 +911,16 @@ msg = "Missing data for required field." with pytest.raises(ValidationError, match=msg): parser.parse(args, web_request) + + +def test_arg_location_param(web_request, parser): + web_request.json = {"foo": 24} + web_request.cookies = {"foo": 42} + args = {"foo": fields.Field(location="cookies")} + + parsed = parser.parse(args, web_request) + + assert parsed["foo"] == 42 def test_validation_errors_in_validator_are_passed_to_handle_error(parser, web_request): @@ -1112,7 +1020,9 @@ class MockRequestParserWithErrorHandler(MockRequestParser): - def handle_error(self, error, req, schema, *, error_status_code, error_headers): + def handle_error( + self, error, req, schema, error_status_code=None, error_headers=None + ): assert isinstance(error, ValidationError) assert isinstance(schema, Schema) raise MockHTTPError(error_status_code, error_headers) @@ -1131,23 +1041,23 @@ assert error.headers == {"X-Foo": "bar"} -@mock.patch("webargs.core.Parser.load_json") -def test_custom_schema_class(load_json, web_request): +@mock.patch("webargs.core.Parser.parse_json") +def test_custom_schema_class(parse_json, web_request): class CustomSchema(Schema): @pre_load def pre_load(self, data, **kwargs): data["value"] += " world" return data - load_json.return_value = {"value": "hello"} + parse_json.return_value = "hello" argmap = {"value": fields.Str()} p = Parser(schema_class=CustomSchema) ret = p.parse(argmap, web_request) assert ret == {"value": "hello world"} -@mock.patch("webargs.core.Parser.load_json") -def test_custom_default_schema_class(load_json, web_request): +@mock.patch("webargs.core.Parser.parse_json") +def test_custom_default_schema_class(parse_json, web_request): class CustomSchema(Schema): @pre_load def pre_load(self, data, **kwargs): @@ -1157,7 +1067,7 @@ class CustomParser(Parser): DEFAULT_SCHEMA_CLASS = CustomSchema - load_json.return_value = {"value": "hello"} + parse_json.return_value = "hello" argmap = {"value": fields.Str()} p = CustomParser() ret = p.parse(argmap, web_request) diff --git a/tests/test_djangoparser.py b/tests/test_djangoparser.py index b4c866a..5b8497a 100644 --- a/tests/test_djangoparser.py +++ b/tests/test_djangoparser.py @@ -1,3 +1,6 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + import pytest from tests.apps.django_app.base.wsgi import application @@ -20,12 +23,12 @@ def test_parsing_in_class_based_view(self, testapp): assert testapp.get("/echo_cbv?name=Fred").json == {"name": "Fred"} - assert testapp.post_json("/echo_cbv", {"name": "Fred"}).json == {"name": "Fred"} + assert testapp.post("/echo_cbv", {"name": "Fred"}).json == {"name": "Fred"} def test_use_args_in_class_based_view(self, testapp): res = testapp.get("/echo_use_args_cbv?name=Fred") assert res.json == {"name": "Fred"} - res = testapp.post_json("/echo_use_args_cbv", {"name": "Fred"}) + res = testapp.post("/echo_use_args_cbv", {"name": "Fred"}) assert res.json == {"name": "Fred"} def test_use_args_in_class_based_view_with_path_param(self, testapp): diff --git a/tests/test_falconparser.py b/tests/test_falconparser.py index 4d2f15c..d6092c7 100644 --- a/tests/test_falconparser.py +++ b/tests/test_falconparser.py @@ -1,5 +1,5 @@ +# -*- coding: utf-8 -*- import pytest -import falcon.testing from webargs.testing import CommonTestCase from tests.apps.falcon_app import create_app @@ -19,7 +19,7 @@ # https://github.com/marshmallow-code/webargs/issues/427 def test_parse_json_with_nonutf8_chars(self, testapp): res = testapp.post( - "/echo_json", + "/echo", b"\xfe", headers={"Accept": "application/json", "Content-Type": "application/json"}, expect_errors=True, @@ -31,22 +31,10 @@ # https://github.com/sloria/webargs/issues/329 def test_invalid_json(self, testapp): res = testapp.post( - "/echo_json", + "/echo", '{"foo": "bar", }', headers={"Accept": "application/json", "Content-Type": "application/json"}, expect_errors=True, ) assert res.status_code == 400 assert res.json["errors"] == {"json": ["Invalid JSON body."]} - - # Falcon converts headers to all-caps - def test_parsing_headers(self, testapp): - res = testapp.get("/echo_headers", headers={"name": "Fred"}) - assert res.json == {"NAME": "Fred"} - - # `falcon.testing.TestClient.simulate_request` parses request with `wsgiref` - def test_body_parsing_works_with_simulate(self): - app = self.create_app() - client = falcon.testing.TestClient(app) - res = client.simulate_post("/echo_json", json={"name": "Fred"},) - assert res.json == {"name": "Fred"} diff --git a/tests/test_flaskparser.py b/tests/test_flaskparser.py index f8ccf36..5122196 100644 --- a/tests/test_flaskparser.py +++ b/tests/test_flaskparser.py @@ -1,20 +1,18 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals +import threading + from werkzeug.exceptions import HTTPException +import mock import pytest from flask import Flask -from webargs import fields, ValidationError, missing, dict2schema +from webargs import fields, ValidationError, missing from webargs.flaskparser import parser, abort from webargs.core import MARSHMALLOW_VERSION_INFO, json from .apps.flask_app import app from webargs.testing import CommonTestCase - -try: - # Python 3.5 - import mock -except ImportError: - # Python 3.6+ - from unittest import mock class TestFlaskParser(CommonTestCase): @@ -28,38 +26,30 @@ def test_parsing_invalid_view_arg(self, testapp): res = testapp.get("/echo_view_arg/foo", expect_errors=True) assert res.status_code == 422 - assert res.json == {"view_args": {"view_arg": ["Not a valid integer."]}} + assert res.json == {"view_arg": ["Not a valid integer."]} def test_use_args_with_view_args_parsing(self, testapp): res = testapp.get("/echo_view_arg_use_args/42") assert res.json == {"view_arg": 42} def test_use_args_on_a_method_view(self, testapp): - res = testapp.post_json("/echo_method_view_use_args", {"val": 42}) + res = testapp.post("/echo_method_view_use_args", {"val": 42}) assert res.json == {"val": 42} def test_use_kwargs_on_a_method_view(self, testapp): - res = testapp.post_json("/echo_method_view_use_kwargs", {"val": 42}) + res = testapp.post("/echo_method_view_use_kwargs", {"val": 42}) assert res.json == {"val": 42} def test_use_kwargs_with_missing_data(self, testapp): - res = testapp.post_json("/echo_use_kwargs_missing", {"username": "foo"}) + res = testapp.post("/echo_use_kwargs_missing", {"username": "foo"}) assert res.json == {"username": "foo"} # regression test for https://github.com/marshmallow-code/webargs/issues/145 def test_nested_many_with_data_key(self, testapp): - post_with_raw_fieldname_args = ( - "/echo_nested_many_data_key", - {"x_field": [{"id": 42}]}, - ) - # under marshmallow 2 this is allowed and works + res = testapp.post_json("/echo_nested_many_data_key", {"x_field": [{"id": 42}]}) + # https://github.com/marshmallow-code/marshmallow/pull/714 if MARSHMALLOW_VERSION_INFO[0] < 3: - res = testapp.post_json(*post_with_raw_fieldname_args) assert res.json == {"x_field": [{"id": 42}]} - # but under marshmallow3 , only data_key is checked, field name is ignored - else: - res = testapp.post_json(*post_with_raw_fieldname_args, expect_errors=True) - assert res.status_code == 422 res = testapp.post_json("/echo_nested_many_data_key", {"X-Field": [{"id": 24}]}) assert res.json == {"x_field": [{"id": 24}]} @@ -87,17 +77,14 @@ abort_args, abort_kwargs = mock_abort.call_args assert abort_args[0] == 422 expected_msg = "Invalid value." - assert abort_kwargs["messages"]["json"]["value"] == [expected_msg] + assert abort_kwargs["messages"]["value"] == [expected_msg] assert type(abort_kwargs["exc"]) == ValidationError -@pytest.mark.parametrize("mimetype", [None, "application/json"]) -def test_load_json_returns_missing_if_no_data(mimetype): +def test_parse_form_returns_missing_if_no_form(): req = mock.Mock() - req.mimetype = mimetype - req.get_data.return_value = "" - schema = dict2schema({"foo": fields.Field()})() - assert parser.load_json(req, schema) is missing + req.form.get.side_effect = AttributeError("no form") + assert parser.parse_form(req, "foo", fields.Field()) is missing def test_abort_with_message(): @@ -124,3 +111,37 @@ error = json.loads(serialized_error) assert isinstance(error, dict) assert error["message"] == "custom error message" + + +def test_json_cache_race_condition(): + app = Flask("testapp") + lock = threading.Lock() + lock.acquire() + + class MyField(fields.Field): + def _deserialize(self, value, attr, data, **kwargs): + with lock: + return value + + argmap = {"value": MyField()} + results = {} + + def thread_fn(value): + with app.test_request_context( + "/foo", + method="post", + data=json.dumps({"value": value}), + content_type="application/json", + ): + results[value] = parser.parse(argmap)["value"] + + t1 = threading.Thread(target=thread_fn, args=(42,)) + t2 = threading.Thread(target=thread_fn, args=(23,)) + t1.start() + t2.start() + lock.release() + t1.join() + t2.join() + # ensure we didn't get contaminated by a parallel request + assert results[42] == 42 + assert results[23] == 23 diff --git a/tests/test_py3/test_aiohttpparser.py b/tests/test_py3/test_aiohttpparser.py index 06a4622..d3de2fb 100644 --- a/tests/test_py3/test_aiohttpparser.py +++ b/tests/test_py3/test_aiohttpparser.py @@ -1,3 +1,5 @@ +# -*- coding: utf-8 -*- + import asyncio import webtest import webtest_aiohttp @@ -36,17 +38,16 @@ # regression test for https://github.com/marshmallow-code/webargs/issues/165 def test_multiple_args(self, testapp): - res = testapp.post_json("/echo_multiple_args", {"first": "1", "last": "2"}) + res = testapp.post_json( + "/echo_multiple_args", {"first": "1", "last": "2", "_ignore": 0} + ) assert res.json == {"first": "1", "last": "2"} # regression test for https://github.com/marshmallow-code/webargs/issues/145 def test_nested_many_with_data_key(self, testapp): + res = testapp.post_json("/echo_nested_many_data_key", {"x_field": [{"id": 42}]}) # https://github.com/marshmallow-code/marshmallow/pull/714 - # on marshmallow 2, the field name can also be used if MARSHMALLOW_VERSION_INFO[0] < 3: - res = testapp.post_json( - "/echo_nested_many_data_key", {"x_field": [{"id": 42}]} - ) assert res.json == {"x_field": [{"id": 42}]} res = testapp.post_json("/echo_nested_many_data_key", {"X-Field": [{"id": 24}]}) diff --git a/tests/test_py3/test_aiohttpparser_async_functions.py b/tests/test_py3/test_aiohttpparser_async_functions.py index 4b732e1..a0437c4 100644 --- a/tests/test_py3/test_aiohttpparser_async_functions.py +++ b/tests/test_py3/test_aiohttpparser_async_functions.py @@ -11,16 +11,16 @@ async def echo_parse(request): - parsed = await parser.parse(hello_args, request, location="query") + parsed = await parser.parse(hello_args, request) return json_response(parsed) -@use_args(hello_args, location="query") +@use_args(hello_args) async def echo_use_args(request, args): return json_response(args) -@use_kwargs(hello_args, location="query") +@use_kwargs(hello_args) async def echo_use_kwargs(request, name): return json_response({"name": name}) diff --git a/tests/test_pyramidparser.py b/tests/test_pyramidparser.py index fb8577c..2f68c7a 100644 --- a/tests/test_pyramidparser.py +++ b/tests/test_pyramidparser.py @@ -1,3 +1,4 @@ +# -*- coding: utf-8 -*- from webargs.testing import CommonTestCase diff --git a/tests/test_tornadoparser.py b/tests/test_tornadoparser.py index 50e281b..8eb2990 100644 --- a/tests/test_tornadoparser.py +++ b/tests/test_tornadoparser.py @@ -1,210 +1,291 @@ +# -*- coding: utf-8 -*- + +from webargs.core import json + +try: + from urllib.parse import urlencode +except ImportError: # PY2 + from urllib import urlencode # type: ignore + +import mock +import pytest + import marshmallow as ma -import pytest + +import tornado.web +import tornado.httputil +import tornado.httpserver +import tornado.http1connection import tornado.concurrent -import tornado.http1connection -import tornado.httpserver -import tornado.httputil import tornado.ioloop -import tornado.web from tornado.testing import AsyncHTTPTestCase + from webargs import fields, missing -from webargs.core import MARSHMALLOW_VERSION_INFO, json, parse_json -from webargs.tornadoparser import ( - WebArgsTornadoMultiDictProxy, - parser, - use_args, - use_kwargs, -) - -from urllib.parse import urlencode - -try: - # Python 3.5 - import mock -except ImportError: - # Python 3.6+ - from unittest import mock - +from webargs.tornadoparser import parser, use_args, use_kwargs, get_value +from webargs.core import parse_json name = "name" value = "value" -class AuthorSchema(ma.Schema): - name = fields.Str(missing="World", validate=lambda n: len(n) >= 3) - works = fields.List(fields.Str()) - - -strict_kwargs = {"strict": True} if MARSHMALLOW_VERSION_INFO[0] < 3 else {} -author_schema = AuthorSchema(**strict_kwargs) - - -def test_tornado_multidictproxy(): - for dictval, fieldname, expected in ( - ({"name": "Sophocles"}, "name", "Sophocles"), - ({"name": "Sophocles"}, "works", missing), - ({"works": ["Antigone", "Oedipus Rex"]}, "works", ["Antigone", "Oedipus Rex"]), - ({"works": ["Antigone", "Oedipus at Colonus"]}, "name", missing), - ): - proxy = WebArgsTornadoMultiDictProxy(dictval, author_schema) - assert proxy.get(fieldname) == expected - - -class TestQueryArgs: +def test_get_value_basic(): + field, multifield = fields.Field(), fields.List(fields.Str()) + assert get_value({"foo": 42}, "foo", field) == 42 + assert get_value({"foo": 42}, "bar", field) is missing + assert get_value({"foos": ["a", "b"]}, "foos", multifield) == ["a", "b"] + # https://github.com/marshmallow-code/webargs/pull/30 + assert get_value({"foos": ["a", "b"]}, "bar", multifield) is missing + + +class TestQueryArgs(object): + def setup_method(self, method): + parser.clear_cache() + def test_it_should_get_single_values(self): - query = [("name", "Aeschylus")] + query = [(name, value)] + field = fields.Field() request = make_get_request(query) - result = parser.load_querystring(request, author_schema) - assert result["name"] == "Aeschylus" + + result = parser.parse_querystring(request, name, field) + + assert result == value def test_it_should_get_multiple_values(self): - query = [("works", "Agamemnon"), ("works", "Nereids")] + query = [(name, value), (name, value)] + field = fields.List(fields.Field()) request = make_get_request(query) - result = parser.load_querystring(request, author_schema) - assert result["works"] == ["Agamemnon", "Nereids"] + + result = parser.parse_querystring(request, name, field) + + assert result == [value, value] def test_it_should_return_missing_if_not_present(self): query = [] + field = fields.Field() + field2 = fields.List(fields.Int()) request = make_get_request(query) - result = parser.load_querystring(request, author_schema) - assert result["name"] is missing - assert result["works"] is missing + + result = parser.parse_querystring(request, name, field) + result2 = parser.parse_querystring(request, name, field2) + + assert result is missing + assert result2 is missing + + def test_it_should_return_empty_list_if_multiple_and_not_present(self): + query = [] + field = fields.List(fields.Field()) + request = make_get_request(query) + + result = parser.parse_querystring(request, name, field) + + assert result is missing class TestFormArgs: + def setup_method(self, method): + parser.clear_cache() + def test_it_should_get_single_values(self): - query = [("name", "Aristophanes")] + query = [(name, value)] + field = fields.Field() request = make_form_request(query) - result = parser.load_form(request, author_schema) - assert result["name"] == "Aristophanes" + + result = parser.parse_form(request, name, field) + + assert result == value def test_it_should_get_multiple_values(self): - query = [("works", "The Wasps"), ("works", "The Frogs")] + query = [(name, value), (name, value)] + field = fields.List(fields.Field()) request = make_form_request(query) - result = parser.load_form(request, author_schema) - assert result["works"] == ["The Wasps", "The Frogs"] + + result = parser.parse_form(request, name, field) + + assert result == [value, value] def test_it_should_return_missing_if_not_present(self): query = [] + field = fields.Field() request = make_form_request(query) - result = parser.load_form(request, author_schema) - assert result["name"] is missing - assert result["works"] is missing - - -class TestJSONArgs: + + result = parser.parse_form(request, name, field) + + assert result is missing + + def test_it_should_return_empty_list_if_multiple_and_not_present(self): + query = [] + field = fields.List(fields.Field()) + request = make_form_request(query) + + result = parser.parse_form(request, name, field) + + assert result is missing + + +class TestJSONArgs(object): + def setup_method(self, method): + parser.clear_cache() + def test_it_should_get_single_values(self): - query = {"name": "Euripides"} + query = {name: value} + field = fields.Field() request = make_json_request(query) - result = parser.load_json(request, author_schema) - assert result["name"] == "Euripides" + result = parser.parse_json(request, name, field) + + assert result == value def test_parsing_request_with_vendor_content_type(self): - query = {"name": "Euripides"} + query = {name: value} + field = fields.Field() request = make_json_request( query, content_type="application/vnd.api+json; charset=UTF-8" ) - result = parser.load_json(request, author_schema) - assert result["name"] == "Euripides" + result = parser.parse_json(request, name, field) + + assert result == value def test_it_should_get_multiple_values(self): - query = {"works": ["Medea", "Electra"]} + query = {name: [value, value]} + field = fields.List(fields.Field()) request = make_json_request(query) - result = parser.load_json(request, author_schema) - assert result["works"] == ["Medea", "Electra"] + result = parser.parse_json(request, name, field) + + assert result == [value, value] def test_it_should_get_multiple_nested_values(self): - class CustomSchema(ma.Schema): - works = fields.List( - fields.Nested({"author": fields.Str(), "workname": fields.Str()}) - ) - - custom_schema = CustomSchema(**strict_kwargs) - - query = { - "works": [ - {"author": "Euripides", "workname": "Hecuba"}, - {"author": "Aristophanes", "workname": "The Birds"}, - ] - } + query = {name: [{"id": 1, "name": "foo"}, {"id": 2, "name": "bar"}]} + field = fields.List( + fields.Nested({"id": fields.Field(), "name": fields.Field()}) + ) request = make_json_request(query) - result = parser.load_json(request, custom_schema) - assert result["works"] == [ - {"author": "Euripides", "workname": "Hecuba"}, - {"author": "Aristophanes", "workname": "The Birds"}, - ] - - def test_it_should_not_include_fieldnames_if_not_present(self): + result = parser.parse_json(request, name, field) + assert result == [{"id": 1, "name": "foo"}, {"id": 2, "name": "bar"}] + + def test_it_should_return_missing_if_not_present(self): query = {} + field = fields.Field() request = make_json_request(query) - result = parser.load_json(request, author_schema) - assert result == {} - - def test_it_should_handle_type_error_on_load_json(self): - # but this is different from the test above where the payload was valid - # and empty -- missing vs {} + result = parser.parse_json(request, name, field) + + assert result is missing + + def test_it_should_return_empty_list_if_multiple_and_not_present(self): + query = {} + field = fields.List(fields.Field()) + request = make_json_request(query) + result = parser.parse_json(request, name, field) + + assert result is missing + + def test_it_should_handle_type_error_on_parse_json(self): + field = fields.Field() request = make_request( - body=tornado.concurrent.Future(), - headers={"Content-Type": "application/json"}, - ) - result = parser.load_json(request, author_schema) + body=tornado.concurrent.Future, headers={"Content-Type": "application/json"} + ) + result = parser.parse_json(request, name, field) + assert parser._cache["json"] == {} assert result is missing def test_it_should_handle_value_error_on_parse_json(self): + field = fields.Field() request = make_request("this is json not") - result = parser.load_json(request, author_schema) - assert result is missing - - -class TestHeadersArgs: + result = parser.parse_json(request, name, field) + assert parser._cache["json"] == {} + assert result is missing + + +class TestHeadersArgs(object): + def setup_method(self, method): + parser.clear_cache() + def test_it_should_get_single_values(self): - query = {"name": "Euphorion"} + query = {name: value} + field = fields.Field() request = make_request(headers=query) - result = parser.load_headers(request, author_schema) - assert result["name"] == "Euphorion" + + result = parser.parse_headers(request, name, field) + + assert result == value def test_it_should_get_multiple_values(self): - query = {"works": ["Prometheus Bound", "Prometheus Unbound"]} + query = {name: [value, value]} + field = fields.List(fields.Field()) request = make_request(headers=query) - result = parser.load_headers(request, author_schema) - assert result["works"] == ["Prometheus Bound", "Prometheus Unbound"] + + result = parser.parse_headers(request, name, field) + + assert result == [value, value] def test_it_should_return_missing_if_not_present(self): + field = fields.Field(multiple=False) request = make_request() - result = parser.load_headers(request, author_schema) - assert result["name"] is missing - assert result["works"] is missing - - -class TestFilesArgs: + + result = parser.parse_headers(request, name, field) + + assert result is missing + + def test_it_should_return_empty_list_if_multiple_and_not_present(self): + query = {} + field = fields.List(fields.Field()) + request = make_request(headers=query) + + result = parser.parse_headers(request, name, field) + + assert result is missing + + +class TestFilesArgs(object): + def setup_method(self, method): + parser.clear_cache() + def test_it_should_get_single_values(self): - query = [("name", "Sappho")] + query = [(name, value)] + field = fields.Field() request = make_files_request(query) - result = parser.load_files(request, author_schema) - assert result["name"] == "Sappho" + + result = parser.parse_files(request, name, field) + + assert result == value def test_it_should_get_multiple_values(self): - query = [("works", "Sappho 31"), ("works", "Ode to Aphrodite")] + query = [(name, value), (name, value)] + field = fields.List(fields.Field()) request = make_files_request(query) - result = parser.load_files(request, author_schema) - assert result["works"] == ["Sappho 31", "Ode to Aphrodite"] + + result = parser.parse_files(request, name, field) + + assert result == [value, value] def test_it_should_return_missing_if_not_present(self): query = [] + field = fields.Field() request = make_files_request(query) - result = parser.load_files(request, author_schema) - assert result["name"] is missing - assert result["works"] is missing - - -class TestErrorHandler: + + result = parser.parse_files(request, name, field) + + assert result is missing + + def test_it_should_return_empty_list_if_multiple_and_not_present(self): + query = [] + field = fields.List(fields.Field()) + request = make_files_request(query) + + result = parser.parse_files(request, name, field) + + assert result is missing + + +class TestErrorHandler(object): def test_it_should_raise_httperror_on_failed_validation(self): args = {"foo": fields.Field(validate=lambda x: False)} with pytest.raises(tornado.web.HTTPError): parser.parse(args, make_json_request({"foo": 42})) -class TestParse: +class TestParse(object): + def setup_method(self, method): + parser.clear_cache() + def test_it_should_parse_query_arguments(self): attrs = {"string": fields.Field(), "integer": fields.List(fields.Int())} @@ -212,7 +293,7 @@ [("string", "value"), ("integer", "1"), ("integer", "2")] ) - parsed = parser.parse(attrs, request, location="query") + parsed = parser.parse(attrs, request) assert parsed["integer"] == [1, 2] assert parsed["string"] == value @@ -224,7 +305,7 @@ [("string", "value"), ("integer", "1"), ("integer", "2")] ) - parsed = parser.parse(attrs, request, location="form") + parsed = parser.parse(attrs, request) assert parsed["integer"] == [1, 2] assert parsed["string"] == value @@ -256,7 +337,7 @@ request = make_request(headers={"string": "value", "integer": ["1", "2"]}) - parsed = parser.parse(attrs, request, location="headers") + parsed = parser.parse(attrs, request, locations=["headers"]) assert parsed["string"] == value assert parsed["integer"] == [1, 2] @@ -268,7 +349,7 @@ [("string", "value"), ("integer", "1"), ("integer", "2")] ) - parsed = parser.parse(attrs, request, location="cookies") + parsed = parser.parse(attrs, request, locations=["cookies"]) assert parsed["string"] == value assert parsed["integer"] == [2] @@ -280,7 +361,7 @@ [("string", "value"), ("integer", "1"), ("integer", "2")] ) - parsed = parser.parse(attrs, request, location="files") + parsed = parser.parse(attrs, request, locations=["files"]) assert parsed["string"] == value assert parsed["integer"] == [1, 2] @@ -302,9 +383,12 @@ parser.parse(args, request) -class TestUseArgs: +class TestUseArgs(object): + def setup_method(self, method): + parser.clear_cache() + def test_it_should_pass_parsed_as_first_argument(self): - class Handler: + class Handler(object): request = make_json_request({"key": "value"}) @use_args({"key": fields.Field()}) @@ -319,7 +403,7 @@ assert result is True def test_it_should_pass_parsed_as_kwargs_arguments(self): - class Handler: + class Handler(object): request = make_json_request({"key": "value"}) @use_kwargs({"key": fields.Field()}) @@ -334,7 +418,7 @@ assert result is True def test_it_should_be_validate_arguments_when_validator_is_passed(self): - class Handler: + class Handler(object): request = make_json_request({"foo": 41}) @use_kwargs({"foo": fields.Int()}, validate=lambda args: args["foo"] > 42) @@ -392,8 +476,8 @@ def make_request(uri=None, body=None, headers=None, files=None): - uri = uri if uri is not None else "" - body = body if body is not None else "" + uri = uri if uri is not None else u"" + body = body if body is not None else u"" method = "POST" if body else "GET" # Need to make a mock connection right now because Tornado 4.0 requires a # remote_ip in the context attribute. 4.1 addresses this, and this @@ -402,7 +486,7 @@ mock_connection = mock.Mock(spec=tornado.http1connection.HTTP1Connection) mock_connection.context = mock.Mock() mock_connection.remote_ip = None - content_type = headers.get("Content-Type", "") if headers else "" + content_type = headers.get("Content-Type", u"") if headers else u"" request = tornado.httputil.HTTPServerRequest( method=method, uri=uri, @@ -425,21 +509,9 @@ class EchoHandler(tornado.web.RequestHandler): ARGS = {"name": fields.Str()} - @use_args(ARGS, location="query") + @use_args(ARGS) def get(self, args): self.write(args) - - -class EchoFormHandler(tornado.web.RequestHandler): - ARGS = {"name": fields.Str()} - - @use_args(ARGS, location="form") - def post(self, args): - self.write(args) - - -class EchoJSONHandler(tornado.web.RequestHandler): - ARGS = {"name": fields.Str()} @use_args(ARGS) def post(self, args): @@ -449,18 +521,13 @@ class EchoWithParamHandler(tornado.web.RequestHandler): ARGS = {"name": fields.Str()} - @use_args(ARGS, location="query") + @use_args(ARGS) def get(self, id, args): self.write(args) echo_app = tornado.web.Application( - [ - (r"/echo", EchoHandler), - (r"/echo_form", EchoFormHandler), - (r"/echo_json", EchoJSONHandler), - (r"/echo_with_param/(\d+)", EchoWithParamHandler), - ] + [(r"/echo", EchoHandler), (r"/echo_with_param/(\d+)", EchoWithParamHandler)] ) @@ -470,7 +537,7 @@ def test_post(self): res = self.fetch( - "/echo_json", + "/echo", method="POST", headers={"Content-Type": "application/json"}, body=json.dumps({"name": "Steve"}), @@ -478,7 +545,7 @@ json_body = parse_json(res.body) assert json_body["name"] == "Steve" res = self.fetch( - "/echo_json", + "/echo", method="POST", headers={"Content-Type": "application/json"}, body=json.dumps({}), @@ -510,7 +577,7 @@ def post(self, args): self.write(args) - @use_kwargs(ARGS, location="query") + @use_kwargs(ARGS) def get(self, name): self.write({"status": "success"}) diff --git a/tests/test_webapp2parser.py b/tests/test_webapp2parser.py index d68593c..f32fda6 100644 --- a/tests/test_webapp2parser.py +++ b/tests/test_webapp2parser.py @@ -1,15 +1,17 @@ +# -*- coding: utf-8 -*- """Tests for the webapp2 parser""" -from urllib.parse import urlencode +try: + from urllib.parse import urlencode +except ImportError: # PY2 + from urllib import urlencode # type: ignore from webargs.core import json import pytest -import marshmallow as ma from marshmallow import fields, ValidationError import webtest import webapp2 from webargs.webapp2parser import parser -from webargs.core import MARSHMALLOW_VERSION_INFO hello_args = {"name": fields.Str(missing="World")} @@ -23,43 +25,32 @@ } -class HelloSchema(ma.Schema): - name = fields.Str(missing="World", validate=lambda n: len(n) >= 3) - - -# variant which ignores unknown fields -exclude_kwargs = ( - {"strict": True} if MARSHMALLOW_VERSION_INFO[0] < 3 else {"unknown": ma.EXCLUDE} -) -hello_exclude_schema = HelloSchema(**exclude_kwargs) - - def test_parse_querystring_args(): request = webapp2.Request.blank("/echo?name=Fred") - assert parser.parse(hello_args, req=request, location="query") == {"name": "Fred"} + assert parser.parse(hello_args, req=request) == {"name": "Fred"} def test_parse_querystring_multiple(): expected = {"name": ["steve", "Loria"]} request = webapp2.Request.blank("/echomulti?name=steve&name=Loria") - assert parser.parse(hello_multiple, req=request, location="query") == expected + assert parser.parse(hello_multiple, req=request) == expected def test_parse_form(): expected = {"name": "Joe"} request = webapp2.Request.blank("/echo", POST=expected) - assert parser.parse(hello_args, req=request, location="form") == expected + assert parser.parse(hello_args, req=request) == expected def test_parse_form_multiple(): expected = {"name": ["steve", "Loria"]} request = webapp2.Request.blank("/echo", POST=urlencode(expected, doseq=True)) - assert parser.parse(hello_multiple, req=request, location="form") == expected + assert parser.parse(hello_multiple, req=request) == expected def test_parsing_form_default(): request = webapp2.Request.blank("/echo", POST="") - assert parser.parse(hello_args, req=request, location="form") == {"name": "World"} + assert parser.parse(hello_args, req=request) == {"name": "World"} def test_parse_json(): @@ -113,15 +104,13 @@ request = webapp2.Request.blank( "/", headers={"Cookie": response.headers["Set-Cookie"]} ) - assert parser.parse(hello_args, req=request, location="cookies") == expected + assert parser.parse(hello_args, req=request, locations=("cookies",)) == expected def test_parsing_headers(): expected = {"name": "Fred"} request = webapp2.Request.blank("/", headers=expected) - assert ( - parser.parse(hello_exclude_schema, req=request, location="headers") == expected - ) + assert parser.parse(hello_args, req=request, locations=("headers",)) == expected def test_parse_files(): @@ -130,14 +119,14 @@ """ class Handler(webapp2.RequestHandler): - @parser.use_args({"myfile": fields.List(fields.Field())}, location="files") + @parser.use_args({"myfile": fields.List(fields.Field())}, locations=("files",)) def post(self, args): self.response.content_type = "application/json" def _value(f): return f.getvalue().decode("utf-8") - data = {i.filename: _value(i.file) for i in args["myfile"]} + data = dict((i.filename, _value(i.file)) for i in args["myfile"]) self.response.write(json.dumps(data)) app = webapp2.WSGIApplication([("/", Handler)]) @@ -150,13 +139,13 @@ def test_exception_on_validation_error(): request = webapp2.Request.blank("/", POST={"num": "3"}) with pytest.raises(ValidationError): - parser.parse(hello_validate, req=request, location="form") + parser.parse(hello_validate, req=request) def test_validation_error_with_message(): request = webapp2.Request.blank("/", POST={"num": "3"}) with pytest.raises(ValidationError) as exc: - parser.parse(hello_validate, req=request, location="form") + parser.parse(hello_validate, req=request) assert "Houston, we've had a problem." in exc.value @@ -168,4 +157,4 @@ request = webapp2.Request.blank("/echo", POST=expected) app = webapp2.WSGIApplication([]) app.set_globals(app, request) - assert parser.parse(hello_args, location="form") == expected + assert parser.parse(hello_args) == expected diff --git a/tox.ini b/tox.ini index 3e67e11..266e149 100644 --- a/tox.ini +++ b/tox.ini @@ -1,9 +1,9 @@ [tox] envlist= lint - py{35,36,37,38}-marshmallow2 - py{35,36,37,38}-marshmallow3 - py38-marshmallowdev + py{27,35,36,37}-marshmallow2 + py{35,36,37}-marshmallow3 + py37-marshmallowdev docs [testenv] @@ -12,10 +12,12 @@ marshmallow2: marshmallow==2.15.2 marshmallow3: marshmallow>=3.0.0rc2,<4.0.0 marshmallowdev: https://github.com/marshmallow-code/marshmallow/archive/dev.tar.gz -commands = pytest {posargs} +commands = + py27: pytest --ignore=tests/test_py3/ {posargs} + py{35,36,37}: pytest {posargs} [testenv:lint] -deps = pre-commit~=1.20 +deps = pre-commit~=1.17 skip_install = true commands = pre-commit run --all-files