From 070d7e725998e49e96f2cecd165f0c2a636d2c11 Mon Sep 17 00:00:00 2001 From: Vincent Maillol Date: Sun, 20 Dec 2020 11:05:24 +0100 Subject: [PATCH 01/26] Increase OAS description Parce docstring of http handlers to increase OAS Remove the not expected definitions key in the OAS --- README.rst | 49 ++++++++- aiohttp_pydantic/oas/cmd.py | 94 +++++++++++++++- aiohttp_pydantic/oas/docstring_parser.py | 120 +++++++++++++++++++++ aiohttp_pydantic/oas/struct.py | 1 + aiohttp_pydantic/oas/view.py | 25 +++-- aiohttp_pydantic/view.py | 10 +- demo/view.py | 29 +++++ requirements/test.txt | 1 + tests/test_oas/test_cmd/oas_base.json | 1 + tests/test_oas/test_cmd/test_cmd.py | 36 +++++-- tests/test_oas/test_docstring_parser.py | 88 +++++++++++++++ tests/test_oas/test_view.py | 132 +++++++---------------- 12 files changed, 470 insertions(+), 116 deletions(-) create mode 100644 aiohttp_pydantic/oas/docstring_parser.py create mode 100644 tests/test_oas/test_cmd/oas_base.json create mode 100644 tests/test_oas/test_docstring_parser.py diff --git a/README.rst b/README.rst index c56fb34..21bb2f6 100644 --- a/README.rst +++ b/README.rst @@ -217,6 +217,9 @@ For example *r200[List[Pet]]* means the server responses with the status code 200 and the response content is a List of Pet where Pet will be defined using a pydantic.BaseModel +The docstring of methods will be parsed to fill the descriptions in the +Open Api Specification. + .. code-block:: python3 @@ -235,20 +238,42 @@ defined using a pydantic.BaseModel class PetCollectionView(PydanticView): async def get(self) -> r200[List[Pet]]: + """ + Find all pets + """ pets = self.request.app["model"].list_pets() return web.json_response([pet.dict() for pet in pets]) async def post(self, pet: Pet) -> r201[Pet]: + """ + Add a new pet to the store + + Status Codes: + 201: The pet is created + """ self.request.app["model"].add_pet(pet) return web.json_response(pet.dict()) class PetItemView(PydanticView): async def get(self, id: int, /) -> Union[r200[Pet], r404[Error]]: + """ + Find a pet by ID + + Status Codes: + 200: Successful operation + 404: Pet not found + """ pet = self.request.app["model"].find_pet(id) return web.json_response(pet.dict()) async def put(self, id: int, /, pet: Pet) -> r200[Pet]: + """ + Update an existing pet + + Status Codes: + 200: successful operation + """ self.request.app["model"].update_pet(id, pet) return web.json_response(pet.dict()) @@ -270,12 +295,34 @@ Have a look at `demo`_ for a complete example Go to http://127.0.0.1:8080/oas -You can generate the OAS in a json file using the command: +You can generate the OAS in a json or yaml file using the aiohttp_pydantic.oas command: .. code-block:: bash python -m aiohttp_pydantic.oas demo.main +.. code-block:: bash + $ python3 -m aiohttp_pydantic.oas --help + usage: __main__.py [-h] [-b FILE] [-o FILE] [-f FORMAT] [APP [APP ...]] + + Generate Open API Specification + + positional arguments: + APP The name of the module containing the asyncio.web.Application. By default the variable named + 'app' is loaded but you can define an other variable name ending the name of module with : + characters and the name of variable. Example: my_package.my_module:my_app If your + asyncio.web.Application is returned by a function, you can use the syntax: + my_package.my_module:my_app() + + optional arguments: + -h, --help show this help message and exit + -b FILE, --base-oas-file FILE + A file that will be used as base to generate OAS + -o FILE, --output FILE + File to write the output + -f FORMAT, --format FORMAT + The output format, can be 'json' or 'yaml' (default is json) + .. _demo: https://github.com/Maillol/aiohttp-pydantic/tree/main/demo .. _aiohttp view: https://docs.aiohttp.org/en/stable/web_quickstart.html#class-based-views diff --git a/aiohttp_pydantic/oas/cmd.py b/aiohttp_pydantic/oas/cmd.py index b0ee01b..8bea0de 100644 --- a/aiohttp_pydantic/oas/cmd.py +++ b/aiohttp_pydantic/oas/cmd.py @@ -1,10 +1,28 @@ import argparse import importlib import json - +from typing import Dict, Protocol, Optional, Callable +import sys from .view import generate_oas +class YamlModule(Protocol): + """ + Yaml Module type hint + """ + + def dump(self, data) -> str: + pass + + +yaml: Optional[YamlModule] + +try: + import yaml +except ImportError: + yaml = None + + def application_type(value): """ Return aiohttp application defined in the value. @@ -26,6 +44,35 @@ def application_type(value): raise argparse.ArgumentTypeError(error) from error +def base_oas_file_type(value) -> Dict: + """ + Load base oas file + """ + try: + with open(value) as oas_file: + data = oas_file.read() + except OSError as error: + raise argparse.ArgumentTypeError(error) from error + + return json.loads(data) + + +def format_type(value) -> Callable: + """ + Date Dumper one of (json, yaml) + """ + dumpers = {"json": lambda data: json.dumps(data, sort_keys=True, indent=4)} + if yaml is not None: + dumpers["yaml"] = yaml.dump + + try: + return dumpers[value] + except KeyError: + raise argparse.ArgumentTypeError( + f"Wrong format value. (allowed values: {tuple(dumpers.keys())})" + ) from None + + def setup(parser: argparse.ArgumentParser): parser.add_argument( "apps", @@ -35,11 +82,52 @@ def setup(parser: argparse.ArgumentParser): help="The name of the module containing the asyncio.web.Application." " By default the variable named 'app' is loaded but you can define" " an other variable name ending the name of module with : characters" - " and the name of variable. Example: my_package.my_module:my_app", + " and the name of variable. Example: my_package.my_module:my_app" + " If your asyncio.web.Application is returned by a function, you can" + " use the syntax: my_package.my_module:my_app()", + ) + parser.add_argument( + "-b", + "--base-oas-file", + metavar="FILE", + dest="base", + type=base_oas_file_type, + help="A file that will be used as base to generate OAS", + default={}, + ) + parser.add_argument( + "-o", + "--output", + metavar="FILE", + type=argparse.FileType("w"), + help="File to write the output", + default=sys.stdout, + ) + + if yaml: + help_output_format = ( + "The output format, can be 'json' or 'yaml' (default is json)" + ) + else: + help_output_format = "The output format, only 'json' is available install pyyaml to have yaml output format" + + parser.add_argument( + "-f", + "--format", + metavar="FORMAT", + dest="formatter", + type=format_type, + help=help_output_format, + default=format_type("json"), ) parser.set_defaults(func=show_oas) def show_oas(args: argparse.Namespace): - print(json.dumps(generate_oas(args.apps), sort_keys=True, indent=4)) + """ + Display Open API Specification on the stdout. + """ + spec = args.base + spec.update(generate_oas(args.apps)) + print(args.formatter(spec), file=args.output) diff --git a/aiohttp_pydantic/oas/docstring_parser.py b/aiohttp_pydantic/oas/docstring_parser.py new file mode 100644 index 0000000..ed2aea7 --- /dev/null +++ b/aiohttp_pydantic/oas/docstring_parser.py @@ -0,0 +1,120 @@ +""" +Utility to extract extra OAS description from docstring. +""" + +import re +import textwrap +from typing import Dict + + +class LinesIterator: + def __init__(self, lines: str): + self._lines = lines.splitlines() + self._i = -1 + + def next_line(self) -> str: + if self._i == len(self._lines) - 1: + raise StopIteration from None + self._i += 1 + return self._lines[self._i] + + def rewind(self) -> str: + if self._i == -1: + raise StopIteration from None + self._i -= 1 + return self._lines[self._i] + + def __iter__(self): + return self + + def __next__(self): + return self.next_line() + + +def _i_extract_block(lines: LinesIterator): + """ + Iter the line within an indented block and dedent them. + """ + + # Go to the first not empty or not white space line. + try: + line = next(lines) + except StopIteration: + return # No block to extract. + while line.strip() == "": + try: + line = next(lines) + except StopIteration: + return + + # Get the size of the indentation. + if (match := re.search("^ +", line)) is None: + return # No block to extract. + indent = match.group() + yield line[len(indent) :] + + # Yield lines until the indentation is the same or is greater than + # the first block line. + try: + line = next(lines) + except StopIteration: + return + while (is_empty := line.strip() == "") or line.startswith(indent): + yield "" if is_empty else line[len(indent) :] + try: + line = next(lines) + except StopIteration: + return + + lines.rewind() + + +def _dedent_under_first_line(text: str) -> str: + """ + Apply textwrap.dedent ignoring the first line. + """ + lines = text.splitlines() + other_lines = "\n".join(lines[1:]) + if other_lines: + return f"{lines[0]}\n{textwrap.dedent(other_lines)}" + return text + + +def status_code(docstring: str) -> Dict[int, str]: + """ + Extract the "Status Code:" block of the docstring. + """ + iterator = LinesIterator(docstring) + for line in iterator: + if re.fullmatch("status\\s+codes?\\s*:", line, re.IGNORECASE): + blocks = [] + lines = [] + for line_of_block in _i_extract_block(iterator): + if re.search("^\\d{3}\\s*:", line_of_block): + if lines: + blocks.append("\n".join(lines)) + lines = [] + lines.append(line_of_block) + if lines: + blocks.append("\n".join(lines)) + + return { + int(status.strip()): _dedent_under_first_line(desc.strip()) + for status, desc in (block.split(":", 1) for block in blocks) + } + return {} + + +def operation(docstring: str) -> str: + """ + Extract all docstring except the "Status Code:" block. + """ + lines = LinesIterator(docstring) + ret = [] + for line in lines: + if re.fullmatch("status\\s+codes?\\s*:", line, re.IGNORECASE): + for _ in _i_extract_block(lines): + pass + else: + ret.append(line) + return ("\n".join(ret)).strip() diff --git a/aiohttp_pydantic/oas/struct.py b/aiohttp_pydantic/oas/struct.py index faa8358..48f9ba9 100644 --- a/aiohttp_pydantic/oas/struct.py +++ b/aiohttp_pydantic/oas/struct.py @@ -133,6 +133,7 @@ class Parameters: class Response: def __init__(self, spec: dict): self._spec = spec + self._spec.setdefault("description", "") @property def description(self) -> str: diff --git a/aiohttp_pydantic/oas/view.py b/aiohttp_pydantic/oas/view.py index 6482e9d..b2e22af 100644 --- a/aiohttp_pydantic/oas/view.py +++ b/aiohttp_pydantic/oas/view.py @@ -8,6 +8,7 @@ from aiohttp.web_app import Application from pydantic import BaseModel from aiohttp_pydantic.oas.struct import OpenApiSpec3, OperationObject, PathItem +from . import docstring_parser from ..injectors import _parse_func_signature from ..utils import is_pydantic_base_model @@ -18,7 +19,7 @@ from .typing import is_status_code_type def _handle_optional(type_): """ Returns the type wrapped in Optional or None. - + >>> from typing import Optional >>> _handle_optional(int) >>> _handle_optional(Optional[str]) @@ -36,14 +37,15 @@ class _OASResponseBuilder: generate the OAS operation response. """ - def __init__(self, oas: OpenApiSpec3, oas_operation): + def __init__(self, oas: OpenApiSpec3, oas_operation, status_code_descriptions): self._oas_operation = oas_operation self._oas = oas + self._status_code_descriptions = status_code_descriptions def _handle_pydantic_base_model(self, obj): if is_pydantic_base_model(obj): response_schema = obj.schema(ref_template="#/components/schemas/{model}") - if def_sub_schemas := response_schema.get("definitions", None): + if def_sub_schemas := response_schema.pop("definitions", None): self._oas.components.schemas.update(def_sub_schemas) return response_schema return {} @@ -64,10 +66,16 @@ class _OASResponseBuilder: "schema": self._handle_list(typing.get_args(obj)[0]) } } + desc = self._status_code_descriptions.get(int(status_code)) + if desc: + self._oas_operation.responses[status_code].description = desc elif is_status_code_type(obj): status_code = obj.__name__[1:] self._oas_operation.responses[status_code].content = {} + desc = self._status_code_descriptions.get(int(status_code)) + if desc: + self._oas_operation.responses[status_code].description = desc def _handle_union(self, obj): if typing.get_origin(obj) is typing.Union: @@ -90,13 +98,16 @@ def _add_http_method_to_oas( ) description = getdoc(handler) if description: - oas_operation.description = description + oas_operation.description = docstring_parser.operation(description) + status_code_descriptions = docstring_parser.status_code(description) + else: + status_code_descriptions = {} if body_args: body_schema = next(iter(body_args.values())).schema( ref_template="#/components/schemas/{model}" ) - if def_sub_schemas := body_schema.get("definitions", None): + if def_sub_schemas := body_schema.pop("definitions", None): oas.components.schemas.update(def_sub_schemas) oas_operation.request_body.content = { @@ -127,7 +138,9 @@ def _add_http_method_to_oas( return_type = handler.__annotations__.get("return") if return_type is not None: - _OASResponseBuilder(oas, oas_operation).build(return_type) + _OASResponseBuilder(oas, oas_operation, status_code_descriptions).build( + return_type + ) def generate_oas(apps: List[Application]) -> dict: diff --git a/aiohttp_pydantic/view.py b/aiohttp_pydantic/view.py index 2218a3a..3030c3b 100644 --- a/aiohttp_pydantic/view.py +++ b/aiohttp_pydantic/view.py @@ -9,8 +9,14 @@ from aiohttp.web_exceptions import HTTPMethodNotAllowed from aiohttp.web_response import StreamResponse from pydantic import ValidationError -from .injectors import (AbstractInjector, BodyGetter, HeadersGetter, - MatchInfoGetter, QueryGetter, _parse_func_signature) +from .injectors import ( + AbstractInjector, + BodyGetter, + HeadersGetter, + MatchInfoGetter, + QueryGetter, + _parse_func_signature, +) class PydanticView(AbstractView): diff --git a/demo/view.py b/demo/view.py index bed6260..aa9b48d 100644 --- a/demo/view.py +++ b/demo/view.py @@ -10,25 +10,54 @@ from .model import Error, Pet class PetCollectionView(PydanticView): async def get(self, age: Optional[int] = None) -> r200[List[Pet]]: + """ + List all pets + + Status Codes: + 200: Successful operation + """ pets = self.request.app["model"].list_pets() return web.json_response( [pet.dict() for pet in pets if age is None or age == pet.age] ) async def post(self, pet: Pet) -> r201[Pet]: + """ + Add a new pet to the store + + Status Codes: + 201: Successful operation + """ self.request.app["model"].add_pet(pet) return web.json_response(pet.dict()) class PetItemView(PydanticView): async def get(self, id: int, /) -> Union[r200[Pet], r404[Error]]: + """ + Find a pet by ID + + Status Codes: + 200: Successful operation + 404: Pet not found + """ pet = self.request.app["model"].find_pet(id) return web.json_response(pet.dict()) async def put(self, id: int, /, pet: Pet) -> r200[Pet]: + """ + Update an existing object + + Status Codes: + 200: Successful operation + 404: Pet not found + """ self.request.app["model"].update_pet(id, pet) return web.json_response(pet.dict()) async def delete(self, id: int, /) -> r204: + """ + Deletes a pet + """ self.request.app["model"].remove_pet(id) return web.Response(status=204) diff --git a/requirements/test.txt b/requirements/test.txt index 4edcdc9..85a1222 100644 --- a/requirements/test.txt +++ b/requirements/test.txt @@ -8,6 +8,7 @@ pyparsing==2.4.7 pytest==6.1.2 pytest-aiohttp==0.3.0 pytest-cov==2.10.1 +pyyaml==5.3.1 six==1.15.0 toml==0.10.2 typing-extensions==3.7.4.3 diff --git a/tests/test_oas/test_cmd/oas_base.json b/tests/test_oas/test_cmd/oas_base.json new file mode 100644 index 0000000..75eb624 --- /dev/null +++ b/tests/test_oas/test_cmd/oas_base.json @@ -0,0 +1 @@ +{"info": {"title": "MyApp", "version": "1.0.0"}} \ No newline at end of file diff --git a/tests/test_oas/test_cmd/test_cmd.py b/tests/test_oas/test_cmd/test_cmd.py index 189e46b..c74e3ee 100644 --- a/tests/test_oas/test_cmd/test_cmd.py +++ b/tests/test_oas/test_cmd/test_cmd.py @@ -1,10 +1,13 @@ import argparse from textwrap import dedent - +from io import StringIO +from pathlib import Path import pytest from aiohttp_pydantic.oas import cmd +PATH_TO_BASE_JSON_FILE = str(Path(__file__).parent / "oas_base.json") + @pytest.fixture def cmd_line(): @@ -13,10 +16,11 @@ def cmd_line(): return parser -def test_show_oad_of_app(cmd_line, capfd): +def test_show_oas_of_app(cmd_line): args = cmd_line.parse_args(["tests.test_oas.test_cmd.sample"]) + args.output = StringIO() args.func(args) - captured = capfd.readouterr() + expected = dedent( """ { @@ -57,13 +61,13 @@ def test_show_oad_of_app(cmd_line, capfd): """ ) - assert captured.out.strip() == expected.strip() + assert args.output.getvalue().strip() == expected.strip() -def test_show_oad_of_sub_app(cmd_line, capfd): +def test_show_oas_of_sub_app(cmd_line): args = cmd_line.parse_args(["tests.test_oas.test_cmd.sample:sub_app"]) + args.output = StringIO() args.func(args) - captured = capfd.readouterr() expected = dedent( """ { @@ -89,16 +93,26 @@ def test_show_oad_of_sub_app(cmd_line, capfd): """ ) - assert captured.out.strip() == expected.strip() + assert args.output.getvalue().strip() == expected.strip() -def test_show_oad_of_a_callable(cmd_line, capfd): - args = cmd_line.parse_args(["tests.test_oas.test_cmd.sample:make_app()"]) +def test_show_oas_of_a_callable(cmd_line): + args = cmd_line.parse_args( + [ + "tests.test_oas.test_cmd.sample:make_app()", + "--base-oas-file", + PATH_TO_BASE_JSON_FILE, + ] + ) + args.output = StringIO() args.func(args) - captured = capfd.readouterr() expected = dedent( """ { + "info": { + "title": "MyApp", + "version": "1.0.0" + }, "openapi": "3.0.0", "paths": { "/route-3/{a}": { @@ -121,4 +135,4 @@ def test_show_oad_of_a_callable(cmd_line, capfd): """ ) - assert captured.out.strip() == expected.strip() + assert args.output.getvalue().strip() == expected.strip() diff --git a/tests/test_oas/test_docstring_parser.py b/tests/test_oas/test_docstring_parser.py new file mode 100644 index 0000000..4794973 --- /dev/null +++ b/tests/test_oas/test_docstring_parser.py @@ -0,0 +1,88 @@ +from aiohttp_pydantic.oas.docstring_parser import ( + status_code, + operation, + _i_extract_block, + LinesIterator, +) +from inspect import getdoc +import pytest + + +def web_handler(): + """ + bla bla bla + + + Status Codes: + 200: line 1 + + line 2: + - line 3 + - line 4 + + line 5 + + 300: line A 1 + + 301: line B 1 + line B 2 + 400: line C 1 + + line C 2 + + line C 3 + + bla bla + """ + + +def test_lines_iterator(): + lines_iterator = LinesIterator("AAAA\nBBBB") + with pytest.raises(StopIteration): + lines_iterator.rewind() + + assert lines_iterator.next_line() == "AAAA" + assert lines_iterator.rewind() + assert lines_iterator.next_line() == "AAAA" + assert lines_iterator.next_line() == "BBBB" + with pytest.raises(StopIteration): + lines_iterator.next_line() + + +def test_status_code(): + + expected = { + 200: "line 1\n\nline 2:\n - line 3\n - line 4\n\nline 5", + 300: "line A 1", + 301: "line B 1\nline B 2", + 400: "line C 1\n\nline C 2\n\n line C 3", + } + + assert status_code(getdoc(web_handler)) == expected + + +def test_operation(): + expected = "bla bla bla\n\n\nbla bla" + assert operation(getdoc(web_handler)) == expected + + +def test_i_extract_block(): + lines = LinesIterator(" aaaa\n\n bbbb\n\n cccc\n dddd") + text = "\n".join(_i_extract_block(lines)) + assert text == """aaaa\n\n bbbb\n\ncccc""" + + lines = LinesIterator("") + text = "\n".join(_i_extract_block(lines)) + assert text == "" + + lines = LinesIterator("aaaa\n bbbb") # the indented block is cut by a new block + text = "\n".join(_i_extract_block(lines)) + assert text == "" + + lines = LinesIterator(" \n ") + text = "\n".join(_i_extract_block(lines)) + assert text == "" + + lines = LinesIterator(" aaaa\n bbbb") + text = "\n".join(_i_extract_block(lines)) + assert text == "aaaa\nbbbb" diff --git a/tests/test_oas/test_view.py b/tests/test_oas/test_view.py index 283acd0..f8b233e 100644 --- a/tests/test_oas/test_view.py +++ b/tests/test_oas/test_view.py @@ -33,6 +33,9 @@ class PetCollectionView(PydanticView): ) -> r200[List[Pet]]: """ Get a list of pets + + Status Codes: + 200: Successful operation """ return web.json_response() @@ -49,6 +52,19 @@ class PetItemView(PydanticView): return web.json_response() async def delete(self, id: int, /) -> r204: + """ + Status Code: + 204: Empty but OK + """ + return web.json_response() + + +class TestResponseReturnASimpleType(PydanticView): + async def get(self) -> r200[int]: + """ + Status Codes: + 200: The new number + """ return web.json_response() @@ -57,6 +73,7 @@ async def generated_oas(aiohttp_client, loop) -> web.Application: app = web.Application() app.router.add_view("/pets", PetCollectionView) app.router.add_view("/pets/{id}", PetItemView) + app.router.add_view("/simple-type", TestResponseReturnASimpleType) oas.setup(app) client = await aiohttp_client(app) @@ -115,29 +132,11 @@ async def test_pets_route_should_have_get_method(generated_oas): ], "responses": { "200": { + "description": "Successful operation", "content": { "application/json": { "schema": { "items": { - "definitions": { - "Color": { - "description": "An enumeration.", - "enum": ["red", "green", "pink"], - "title": "Color", - "type": "string", - }, - "Toy": { - "properties": { - "color": { - "$ref": "#/components/schemas/Color" - }, - "name": {"title": "Name", "type": "string"}, - }, - "required": ["name", "color"], - "title": "Toy", - "type": "object", - }, - }, "properties": { "id": {"title": "Id", "type": "integer"}, "name": {"title": "Name", "type": "string"}, @@ -154,7 +153,7 @@ async def test_pets_route_should_have_get_method(generated_oas): "type": "array", } } - } + }, } }, } @@ -167,23 +166,6 @@ async def test_pets_route_should_have_post_method(generated_oas): "content": { "application/json": { "schema": { - "definitions": { - "Color": { - "description": "An enumeration.", - "enum": ["red", "green", "pink"], - "title": "Color", - "type": "string", - }, - "Toy": { - "properties": { - "color": {"$ref": "#/components/schemas/Color"}, - "name": {"title": "Name", "type": "string"}, - }, - "required": ["name", "color"], - "title": "Toy", - "type": "object", - }, - }, "properties": { "id": {"title": "Id", "type": "integer"}, "name": {"title": "Name", "type": "string"}, @@ -202,26 +184,10 @@ async def test_pets_route_should_have_post_method(generated_oas): }, "responses": { "201": { + "description": "", "content": { "application/json": { "schema": { - "definitions": { - "Color": { - "description": "An enumeration.", - "enum": ["red", "green", "pink"], - "title": "Color", - "type": "string", - }, - "Toy": { - "properties": { - "color": {"$ref": "#/components/schemas/Color"}, - "name": {"title": "Name", "type": "string"}, - }, - "required": ["name", "color"], - "title": "Toy", - "type": "object", - }, - }, "properties": { "id": {"title": "Id", "type": "integer"}, "name": {"title": "Name", "type": "string"}, @@ -236,7 +202,7 @@ async def test_pets_route_should_have_post_method(generated_oas): "type": "object", } } - } + }, } }, } @@ -248,15 +214,16 @@ async def test_generated_oas_should_have_pets_id_paths(generated_oas): async def test_pets_id_route_should_have_delete_method(generated_oas): assert generated_oas["paths"]["/pets/{id}"]["delete"] == { + "description": "", "parameters": [ { - "required": True, "in": "path", "name": "id", + "required": True, "schema": {"title": "id", "type": "integer"}, } ], - "responses": {"204": {"content": {}}}, + "responses": {"204": {"content": {}, "description": "Empty but OK"}}, } @@ -272,26 +239,10 @@ async def test_pets_id_route_should_have_get_method(generated_oas): ], "responses": { "200": { + "description": "", "content": { "application/json": { "schema": { - "definitions": { - "Color": { - "description": "An enumeration.", - "enum": ["red", "green", "pink"], - "title": "Color", - "type": "string", - }, - "Toy": { - "properties": { - "color": {"$ref": "#/components/schemas/Color"}, - "name": {"title": "Name", "type": "string"}, - }, - "required": ["name", "color"], - "title": "Toy", - "type": "object", - }, - }, "properties": { "id": {"title": "Id", "type": "integer"}, "name": {"title": "Name", "type": "string"}, @@ -306,9 +257,9 @@ async def test_pets_id_route_should_have_get_method(generated_oas): "type": "object", } } - } + }, }, - "404": {"content": {}}, + "404": {"description": "", "content": {}}, }, } @@ -327,23 +278,6 @@ async def test_pets_id_route_should_have_put_method(generated_oas): "content": { "application/json": { "schema": { - "definitions": { - "Color": { - "description": "An enumeration.", - "enum": ["red", "green", "pink"], - "title": "Color", - "type": "string", - }, - "Toy": { - "properties": { - "color": {"$ref": "#/components/schemas/Color"}, - "name": {"title": "Name", "type": "string"}, - }, - "required": ["name", "color"], - "title": "Toy", - "type": "object", - }, - }, "properties": { "id": {"title": "Id", "type": "integer"}, "name": {"title": "Name", "type": "string"}, @@ -361,3 +295,15 @@ async def test_pets_id_route_should_have_put_method(generated_oas): } }, } + + +async def test_simple_type_route_should_have_get_method(generated_oas): + assert generated_oas["paths"]["/simple-type"]["get"] == { + "description": "", + "responses": { + "200": { + "content": {"application/json": {"schema": {}}}, + "description": "The new number", + } + }, + } From 071395e8bde728bd756e35fa39c1049cd6538ab9 Mon Sep 17 00:00:00 2001 From: Vincent Maillol Date: Sun, 20 Dec 2020 13:20:48 +0100 Subject: [PATCH 02/26] Update version 1.7.0 --- aiohttp_pydantic/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aiohttp_pydantic/__init__.py b/aiohttp_pydantic/__init__.py index dabdbee..079e5ca 100644 --- a/aiohttp_pydantic/__init__.py +++ b/aiohttp_pydantic/__init__.py @@ -1,5 +1,5 @@ from .view import PydanticView -__version__ = "1.6.1" +__version__ = "1.7.0" __all__ = ("PydanticView", "__version__") From 4ff9739293a28a1cbfe728521769cd4dcd682689 Mon Sep 17 00:00:00 2001 From: Vincent Maillol Date: Sun, 20 Dec 2020 17:06:17 +0100 Subject: [PATCH 03/26] version=1.7.1 fix README render, force twine check because travis does not mount error. --- .travis.yml | 2 ++ README.rst | 1 + aiohttp_pydantic/__init__.py | 2 +- requirements/ci.txt | 1 + setup.cfg | 2 +- 5 files changed, 6 insertions(+), 2 deletions(-) diff --git a/.travis.yml b/.travis.yml index 3aa319e..f32b871 100644 --- a/.travis.yml +++ b/.travis.yml @@ -3,6 +3,7 @@ python: - '3.8' script: - pytest --cov-report=xml --cov=aiohttp_pydantic tests/ +- sh -c 'python -m readme_renderer README.rst > /dev/null' install: - pip install -U setuptools wheel pip - pip install -r requirements/test.txt @@ -12,6 +13,7 @@ after_success: - codecov deploy: provider: pypi + twine_check: true username: __token__ password: secure: ki81Limjj8UgsX1GNpOF2+vYjc6GEPY1V9BbJkQl+5WVTynqKTDEi+jekx8Id0jYEGGQ8/PfTiXe7dY/MqfQ0oWQ5+UNmGZIQJwYCft4FJWrI5QoL1LE0tqKpXCzBX7rGr1BOdvToS9zwf3RDr1u7ib16V/xakX55raVpQ37ttE0cKEPzvq6MqZTfYvq0VnhPmTDbTDBd9krHHAAG5lVhm9oAbp9TkhKsWDuA+wGzgKt2tuPX6+Le4op/wiiBhAnhvcVzjDWaX8dxd3Ac0XlnPtl8EMe5lJJez/ahGedydwGDJC75TOl1b7WP9AqogvNISVN+2VYUVxkgoK9yC9zEjhCSWKHSz+t8ZddB+itYHvj9lMf04iObq8OSUcD71R4rASWMZ89YdksWb6qvD+md1oEl/M6JSyZAkv+aedFL5iyKS4oJpZT3fYYloUqhF3/aDVgC3mlnXVsxC2cCIdpvu2EVjpFqFJ+9qGpp3ZlhRfDkjbQA0IA6KXKaWkIadQouJ4Wr1WtXjN4w0QlAvGV/q3m4bQ3ZZGxYipS9MQwDnUoRYtrX6j7bsaXjBdfhPNlwzgHQDPbD//oX9ZI1Oe6+kT/WKQvBrtvftv+TUhQ49uePHn5o/eYAKh35IwYTBxLgk2t483k0ZI5cjVXd2zGRgAxPdB/XyGW84dJGPJNn8o= diff --git a/README.rst b/README.rst index 21bb2f6..dd8cd69 100644 --- a/README.rst +++ b/README.rst @@ -302,6 +302,7 @@ You can generate the OAS in a json or yaml file using the aiohttp_pydantic.oas c python -m aiohttp_pydantic.oas demo.main .. code-block:: bash + $ python3 -m aiohttp_pydantic.oas --help usage: __main__.py [-h] [-b FILE] [-o FILE] [-f FORMAT] [APP [APP ...]] diff --git a/aiohttp_pydantic/__init__.py b/aiohttp_pydantic/__init__.py index 079e5ca..91b5e3c 100644 --- a/aiohttp_pydantic/__init__.py +++ b/aiohttp_pydantic/__init__.py @@ -1,5 +1,5 @@ from .view import PydanticView -__version__ = "1.7.0" +__version__ = "1.7.1" __all__ = ("PydanticView", "__version__") diff --git a/requirements/ci.txt b/requirements/ci.txt index ce4b7a7..3380aa6 100644 --- a/requirements/ci.txt +++ b/requirements/ci.txt @@ -3,5 +3,6 @@ chardet==3.0.4 codecov==2.1.10 coverage==5.3 idna==2.10 +readme-renderer==26.0 requests==2.25.0 urllib3==1.26.2 diff --git a/setup.cfg b/setup.cfg index 32dc85e..f9d9c96 100644 --- a/setup.cfg +++ b/setup.cfg @@ -36,7 +36,7 @@ install_requires = [options.extras_require] test = pytest==6.1.2; pytest-aiohttp==0.3.0; pytest-cov==2.10.1 -ci = pytest==6.1.2; pytest-aiohttp==0.3.0; pytest-cov==2.10.1; codecov==2.1.10 +ci = pytest==6.1.2; pytest-aiohttp==0.3.0; pytest-cov==2.10.1; codecov==2.1.10; readme-renderer==26.0 [options.packages.find] exclude = From c6b979dcaf950a1e8719e8f94825f65f8ef951c9 Mon Sep 17 00:00:00 2001 From: Vincent Maillol Date: Sat, 27 Feb 2021 08:08:12 +0100 Subject: [PATCH 04/26] version=1.7.2 fix oas.spec schema is broken after reloading the page --- aiohttp_pydantic/__init__.py | 2 +- aiohttp_pydantic/oas/view.py | 10 +++++++--- tests/test_oas/test_view.py | 17 +++++++++++++---- 3 files changed, 21 insertions(+), 8 deletions(-) diff --git a/aiohttp_pydantic/__init__.py b/aiohttp_pydantic/__init__.py index 91b5e3c..e4c4db7 100644 --- a/aiohttp_pydantic/__init__.py +++ b/aiohttp_pydantic/__init__.py @@ -1,5 +1,5 @@ from .view import PydanticView -__version__ = "1.7.1" +__version__ = "1.7.2" __all__ = ("PydanticView", "__version__") diff --git a/aiohttp_pydantic/oas/view.py b/aiohttp_pydantic/oas/view.py index b2e22af..4aab93f 100644 --- a/aiohttp_pydantic/oas/view.py +++ b/aiohttp_pydantic/oas/view.py @@ -44,7 +44,9 @@ class _OASResponseBuilder: def _handle_pydantic_base_model(self, obj): if is_pydantic_base_model(obj): - response_schema = obj.schema(ref_template="#/components/schemas/{model}") + response_schema = obj.schema( + ref_template="#/components/schemas/{model}" + ).copy() if def_sub_schemas := response_schema.pop("definitions", None): self._oas.components.schemas.update(def_sub_schemas) return response_schema @@ -104,8 +106,10 @@ def _add_http_method_to_oas( status_code_descriptions = {} if body_args: - body_schema = next(iter(body_args.values())).schema( - ref_template="#/components/schemas/{model}" + body_schema = ( + next(iter(body_args.values())) + .schema(ref_template="#/components/schemas/{model}") + .copy() ) if def_sub_schemas := body_schema.pop("definitions", None): oas.components.schemas.update(def_sub_schemas) diff --git a/tests/test_oas/test_view.py b/tests/test_oas/test_view.py index f8b233e..acdbe83 100644 --- a/tests/test_oas/test_view.py +++ b/tests/test_oas/test_view.py @@ -77,10 +77,19 @@ async def generated_oas(aiohttp_client, loop) -> web.Application: oas.setup(app) client = await aiohttp_client(app) - response = await client.get("/oas/spec") - assert response.status == 200 - assert response.content_type == "application/json" - return await response.json() + response_1 = await client.get("/oas/spec") + assert response_1.content_type == "application/json" + assert response_1.status == 200 + content_1 = await response_1.json() + + # Reload the page to ensure that content is always the same + # note: pydantic can return a cached dict, if a view updates + # the dict the output will be incoherent + response_2 = await client.get("/oas/spec") + content_2 = await response_2.json() + assert content_1 == content_2 + + return content_2 async def test_generated_oas_should_have_components_schemas(generated_oas): From 81d4e93a1dc99aef9c9362511452d19a5200930f Mon Sep 17 00:00:00 2001 From: Daan de Ruiter <30779179+drderuiter@users.noreply.github.com> Date: Fri, 5 Mar 2021 21:46:13 +0100 Subject: [PATCH 05/26] Prevent internal server error when receiving a JSON request body with non-object top-level structure (#9) Prevent internal server error when receiving a JSON request body with non-object top-level structure --- aiohttp_pydantic/__init__.py | 2 +- aiohttp_pydantic/injectors.py | 12 ++++++- tests/test_validation_body.py | 64 ++++++++++++++++++++++++++++++++++- 3 files changed, 75 insertions(+), 3 deletions(-) diff --git a/aiohttp_pydantic/__init__.py b/aiohttp_pydantic/__init__.py index e4c4db7..0524b62 100644 --- a/aiohttp_pydantic/__init__.py +++ b/aiohttp_pydantic/__init__.py @@ -1,5 +1,5 @@ from .view import PydanticView -__version__ = "1.7.2" +__version__ = "1.8.0" __all__ = ("PydanticView", "__version__") diff --git a/aiohttp_pydantic/injectors.py b/aiohttp_pydantic/injectors.py index 75284ae..2c65b81 100644 --- a/aiohttp_pydantic/injectors.py +++ b/aiohttp_pydantic/injectors.py @@ -61,6 +61,7 @@ class BodyGetter(AbstractInjector): def __init__(self, args_spec: dict, default_values: dict): self.arg_name, self.model = next(iter(args_spec.items())) + self._expect_object = self.model.schema()["type"] == "object" async def inject(self, request: BaseRequest, args_view: list, kwargs_view: dict): try: @@ -70,7 +71,16 @@ class BodyGetter(AbstractInjector): text='{"error": "Malformed JSON"}', content_type="application/json" ) from None - kwargs_view[self.arg_name] = self.model(**body) + # Pydantic tries to cast certain structures, such as a list of 2-tuples, + # to a dict. Prevent this by requiring the body to be a dict for object models. + if self._expect_object and not isinstance(body, dict): + raise HTTPBadRequest( + text='[{"in": "body", "loc": ["__root__"], "msg": "value is not a ' + 'valid dict", "type": "type_error.dict"}]', + content_type="application/json", + ) from None + + kwargs_view[self.arg_name] = self.model.parse_obj(body) class QueryGetter(AbstractInjector): diff --git a/tests/test_validation_body.py b/tests/test_validation_body.py index 424f136..6a7fdd7 100644 --- a/tests/test_validation_body.py +++ b/tests/test_validation_body.py @@ -1,4 +1,4 @@ -from typing import Optional +from typing import Iterator, List, Optional from aiohttp import web from pydantic import BaseModel @@ -11,10 +11,20 @@ class ArticleModel(BaseModel): nb_page: Optional[int] +class ArticleModels(BaseModel): + __root__: List[ArticleModel] + + def __iter__(self) -> Iterator[ArticleModel]: + return iter(self.__root__) + + class ArticleView(PydanticView): async def post(self, article: ArticleModel): return web.json_response(article.dict()) + async def put(self, articles: ArticleModels): + return web.json_response([article.dict() for article in articles]) + async def test_post_an_article_without_required_field_should_return_an_error_message( aiohttp_client, loop @@ -56,6 +66,58 @@ async def test_post_an_article_with_wrong_type_field_should_return_an_error_mess ] +async def test_post_an_array_json_is_supported(aiohttp_client, loop): + app = web.Application() + app.router.add_view("/article", ArticleView) + + client = await aiohttp_client(app) + body = [{"name": "foo", "nb_page": 3}] * 2 + resp = await client.put("/article", json=body) + assert resp.status == 200 + assert resp.content_type == "application/json" + assert await resp.json() == body + + +async def test_post_an_array_json_to_an_object_model_should_return_an_error( + aiohttp_client, loop +): + app = web.Application() + app.router.add_view("/article", ArticleView) + + client = await aiohttp_client(app) + resp = await client.post("/article", json=[{"name": "foo", "nb_page": 3}]) + assert resp.status == 400 + assert resp.content_type == "application/json" + assert await resp.json() == [ + { + "in": "body", + "loc": ["__root__"], + "msg": "value is not a valid dict", + "type": "type_error.dict", + } + ] + + +async def test_post_an_object_json_to_a_list_model_should_return_an_error( + aiohttp_client, loop +): + app = web.Application() + app.router.add_view("/article", ArticleView) + + client = await aiohttp_client(app) + resp = await client.put("/article", json={"name": "foo", "nb_page": 3}) + assert resp.status == 400 + assert resp.content_type == "application/json" + assert await resp.json() == [ + { + "in": "body", + "loc": ["__root__"], + "msg": "value is not a valid list", + "type": "type_error.list", + } + ] + + async def test_post_a_valid_article_should_return_the_parsed_type(aiohttp_client, loop): app = web.Application() app.router.add_view("/article", ArticleView) From 145d2fc0f2183b4634023ed11676f3091b5bacff Mon Sep 17 00:00:00 2001 From: MAILLOL Vincent Date: Sat, 27 Mar 2021 11:56:19 +0100 Subject: [PATCH 06/26] query string accept multiple values for same parameter key (#11) --- README.rst | 25 ++++++-- aiohttp_pydantic/injectors.py | 22 ++++++- tests/test_oas/test_view.py | 4 +- tests/test_validation_query_string.py | 88 +++++++++++++++++++++++++-- 4 files changed, 126 insertions(+), 13 deletions(-) diff --git a/README.rst b/README.rst index dd8cd69..7ad0c03 100644 --- a/README.rst +++ b/README.rst @@ -54,7 +54,7 @@ Example: return web.json_response({'name': article.name, 'number_of_page': article.nb_page}) - async def get(self, with_comments: Optional[bool]): + async def get(self, with_comments: bool=False): return web.json_response({'with_comments': with_comments}) @@ -101,7 +101,7 @@ API: Inject Path Parameters ~~~~~~~~~~~~~~~~~~~~~~ -To declare a path parameters, you must declare your argument as a `positional-only parameters`_: +To declare a path parameter, you must declare your argument as a `positional-only parameters`_: Example: @@ -118,18 +118,33 @@ Example: Inject Query String Parameters ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -To declare a query parameters, you must declare your argument as a simple argument: +To declare a query parameter, you must declare your argument as a simple argument: .. code-block:: python3 class AccountView(PydanticView): - async def get(self, customer_id: str): + async def get(self, customer_id: Optional[str] = None): ... app = web.Application() app.router.add_get('/customers', AccountView) + +A query string parameter is generally optional and we do not want to force the user to set it in the URL. +It's recommended to define a default value. It's possible to get a multiple value for the same parameter using +the List type + +.. code-block:: python3 + + class AccountView(PydanticView): + async def get(self, tags: List[str] = []): + ... + + app = web.Application() + app.router.add_get('/customers', AccountView) + + Inject Request Body ~~~~~~~~~~~~~~~~~~~ @@ -152,7 +167,7 @@ To declare a body parameter, you must declare your argument as a simple argument Inject HTTP headers ~~~~~~~~~~~~~~~~~~~ -To declare a HTTP headers parameters, you must declare your argument as a `keyword-only argument`_. +To declare a HTTP headers parameter, you must declare your argument as a `keyword-only argument`_. .. code-block:: python3 diff --git a/aiohttp_pydantic/injectors.py b/aiohttp_pydantic/injectors.py index 2c65b81..1f5ae24 100644 --- a/aiohttp_pydantic/injectors.py +++ b/aiohttp_pydantic/injectors.py @@ -1,10 +1,12 @@ import abc +import typing from inspect import signature from json.decoder import JSONDecodeError from typing import Callable, Tuple from aiohttp.web_exceptions import HTTPBadRequest from aiohttp.web_request import BaseRequest +from multidict import MultiDict from pydantic import BaseModel from .utils import is_pydantic_base_model @@ -94,9 +96,27 @@ class QueryGetter(AbstractInjector): attrs = {"__annotations__": args_spec} attrs.update(default_values) self.model = type("QueryModel", (BaseModel,), attrs) + self.args_spec = args_spec + self._is_multiple = frozenset( + name for name, spec in args_spec.items() if typing.get_origin(spec) is list + ) def inject(self, request: BaseRequest, args_view: list, kwargs_view: dict): - kwargs_view.update(self.model(**request.query).dict()) + kwargs_view.update(self.model(**self._query_to_dict(request.query)).dict()) + + def _query_to_dict(self, query: MultiDict): + """ + Return a dict with list as value from the MultiDict. + + The value will be wrapped in a list if the args spec is define as a list or if + the multiple values are sent (i.e ?foo=1&foo=2) + """ + return { + key: values + if len(values := query.getall(key)) > 1 or key in self._is_multiple + else value + for key, value in query.items() + } class HeadersGetter(AbstractInjector): diff --git a/tests/test_oas/test_view.py b/tests/test_oas/test_view.py index acdbe83..adf653f 100644 --- a/tests/test_oas/test_view.py +++ b/tests/test_oas/test_view.py @@ -59,7 +59,7 @@ class PetItemView(PydanticView): return web.json_response() -class TestResponseReturnASimpleType(PydanticView): +class ViewResponseReturnASimpleType(PydanticView): async def get(self) -> r200[int]: """ Status Codes: @@ -73,7 +73,7 @@ async def generated_oas(aiohttp_client, loop) -> web.Application: app = web.Application() app.router.add_view("/pets", PetCollectionView) app.router.add_view("/pets/{id}", PetItemView) - app.router.add_view("/simple-type", TestResponseReturnASimpleType) + app.router.add_view("/simple-type", ViewResponseReturnASimpleType) oas.setup(app) client = await aiohttp_client(app) diff --git a/tests/test_validation_query_string.py b/tests/test_validation_query_string.py index 363c461..b25ff43 100644 --- a/tests/test_validation_query_string.py +++ b/tests/test_validation_query_string.py @@ -1,4 +1,4 @@ -from typing import Optional +from typing import Optional, List from aiohttp import web @@ -7,10 +7,19 @@ from aiohttp_pydantic import PydanticView class ArticleView(PydanticView): async def get( - self, with_comments: bool, age: Optional[int] = None, nb_items: int = 7 + self, + with_comments: bool, + age: Optional[int] = None, + nb_items: int = 7, + tags: List[str] = [], ): return web.json_response( - {"with_comments": with_comments, "age": age, "nb_items": nb_items} + { + "with_comments": with_comments, + "age": age, + "nb_items": nb_items, + "tags": tags, + } ) @@ -65,7 +74,12 @@ async def test_get_article_with_valid_qs_should_return_the_parsed_type( resp = await client.get("/article", params={"with_comments": "yes", "age": 3}) assert resp.status == 200 assert resp.content_type == "application/json" - assert await resp.json() == {"with_comments": True, "age": 3, "nb_items": 7} + assert await resp.json() == { + "with_comments": True, + "age": 3, + "nb_items": 7, + "tags": [], + } async def test_get_article_with_valid_qs_and_omitted_optional_should_return_default_value( @@ -77,6 +91,70 @@ async def test_get_article_with_valid_qs_and_omitted_optional_should_return_defa client = await aiohttp_client(app) resp = await client.get("/article", params={"with_comments": "yes"}) - assert await resp.json() == {"with_comments": True, "age": None, "nb_items": 7} + assert await resp.json() == { + "with_comments": True, + "age": None, + "nb_items": 7, + "tags": [], + } + assert resp.status == 200 + assert resp.content_type == "application/json" + + +async def test_get_article_with_multiple_value_for_qs_age_must_failed( + aiohttp_client, loop +): + app = web.Application() + app.router.add_view("/article", ArticleView) + + client = await aiohttp_client(app) + + resp = await client.get("/article", params={"age": ["2", "3"], "with_comments": 1}) + assert await resp.json() == [ + { + "in": "query string", + "loc": ["age"], + "msg": "value is not a valid integer", + "type": "type_error.integer", + } + ] + assert resp.status == 400 + assert resp.content_type == "application/json" + + +async def test_get_article_with_multiple_value_of_tags(aiohttp_client, loop): + app = web.Application() + app.router.add_view("/article", ArticleView) + + client = await aiohttp_client(app) + + resp = await client.get( + "/article", params={"age": 2, "with_comments": 1, "tags": ["aa", "bb"]} + ) + assert await resp.json() == { + "age": 2, + "nb_items": 7, + "tags": ["aa", "bb"], + "with_comments": True, + } + assert resp.status == 200 + assert resp.content_type == "application/json" + + +async def test_get_article_with_one_value_of_tags_must_be_a_list(aiohttp_client, loop): + app = web.Application() + app.router.add_view("/article", ArticleView) + + client = await aiohttp_client(app) + + resp = await client.get( + "/article", params={"age": 2, "with_comments": 1, "tags": ["aa"]} + ) + assert await resp.json() == { + "age": 2, + "nb_items": 7, + "tags": ["aa"], + "with_comments": True, + } assert resp.status == 200 assert resp.content_type == "application/json" From 7492af5acf20c4d325ec863b9987f6107a05abd7 Mon Sep 17 00:00:00 2001 From: Vincent Maillol Date: Sat, 27 Mar 2021 12:34:39 +0100 Subject: [PATCH 07/26] Update version 1.8.1 --- README.rst | 5 ++++- aiohttp_pydantic/__init__.py | 2 +- tests/test_validation_query_string.py | 4 ++-- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/README.rst b/README.rst index 7ad0c03..a0a2f43 100644 --- a/README.rst +++ b/README.rst @@ -137,8 +137,11 @@ the List type .. code-block:: python3 + from typing import List + from pydantic import Field + class AccountView(PydanticView): - async def get(self, tags: List[str] = []): + async def get(self, tags: List[str] = Field(default_factory=list)): ... app = web.Application() diff --git a/aiohttp_pydantic/__init__.py b/aiohttp_pydantic/__init__.py index 0524b62..b8a6eec 100644 --- a/aiohttp_pydantic/__init__.py +++ b/aiohttp_pydantic/__init__.py @@ -1,5 +1,5 @@ from .view import PydanticView -__version__ = "1.8.0" +__version__ = "1.8.1" __all__ = ("PydanticView", "__version__") diff --git a/tests/test_validation_query_string.py b/tests/test_validation_query_string.py index b25ff43..57d886e 100644 --- a/tests/test_validation_query_string.py +++ b/tests/test_validation_query_string.py @@ -1,5 +1,5 @@ from typing import Optional, List - +from pydantic import Field from aiohttp import web from aiohttp_pydantic import PydanticView @@ -11,7 +11,7 @@ class ArticleView(PydanticView): with_comments: bool, age: Optional[int] = None, nb_items: int = 7, - tags: List[str] = [], + tags: List[str] = Field(default_factory=list) ): return web.json_response( { From beb638c0af653b4b8e128e64b42b81ddd7f6f23c Mon Sep 17 00:00:00 2001 From: spinenkoia Date: Sun, 4 Apr 2021 16:22:05 +0500 Subject: [PATCH 08/26] Added a wrapper for get_oas to throw spec info (#12) (#13) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Added a wrapper for get_oas to throw spec info (#12) * Added tests generate_oas * Moved params to Application Co-authored-by: Спиненко Иван ispinenko@ussc.ru --- aiohttp_pydantic/oas/__init__.py | 7 +++++- aiohttp_pydantic/oas/struct.py | 2 +- aiohttp_pydantic/oas/view.py | 15 ++++++++--- demo/main.py | 2 +- tests/test_oas/test_cmd/test_cmd.py | 18 ++++++++++---- tests/test_oas/test_struct/test_info.py | 29 +++++++++++++++++----- tests/test_oas/test_struct/test_paths.py | 11 +++++++- tests/test_oas/test_struct/test_servers.py | 2 ++ tests/test_oas/test_view.py | 21 ++++++++++++++++ 9 files changed, 89 insertions(+), 18 deletions(-) diff --git a/aiohttp_pydantic/oas/__init__.py b/aiohttp_pydantic/oas/__init__.py index ef45dcf..1e26791 100644 --- a/aiohttp_pydantic/oas/__init__.py +++ b/aiohttp_pydantic/oas/__init__.py @@ -1,5 +1,5 @@ from importlib import resources -from typing import Iterable +from typing import Iterable, Optional import jinja2 from aiohttp import web @@ -13,6 +13,8 @@ def setup( apps_to_expose: Iterable[web.Application] = (), url_prefix: str = "/oas", enable: bool = True, + version_spec: Optional[str] = None, + title_spec: Optional[str] = None, ): if enable: oas_app = web.Application() @@ -20,6 +22,9 @@ def setup( oas_app["index template"] = jinja2.Template( resources.read_text("aiohttp_pydantic.oas", "index.j2") ) + oas_app["version_spec"] = version_spec + oas_app["title_spec"] = title_spec + oas_app.router.add_get("/spec", get_oas, name="spec") oas_app.router.add_static("/static", swagger_ui_path, name="static") oas_app.router.add_get("", oas_ui, name="index") diff --git a/aiohttp_pydantic/oas/struct.py b/aiohttp_pydantic/oas/struct.py index 48f9ba9..576079a 100644 --- a/aiohttp_pydantic/oas/struct.py +++ b/aiohttp_pydantic/oas/struct.py @@ -305,7 +305,7 @@ class Components: class OpenApiSpec3: def __init__(self): - self._spec = {"openapi": "3.0.0"} + self._spec = {"openapi": "3.0.0", "info": {"version": "1.0.0", "title": "Aiohttp pydantic application"}} @property def info(self) -> Info: diff --git a/aiohttp_pydantic/oas/view.py b/aiohttp_pydantic/oas/view.py index 4aab93f..d91bb8e 100644 --- a/aiohttp_pydantic/oas/view.py +++ b/aiohttp_pydantic/oas/view.py @@ -1,7 +1,7 @@ import typing from inspect import getdoc from itertools import count -from typing import List, Type +from typing import List, Type, Optional, Dict from aiohttp.web import Response, json_response from aiohttp.web_app import Application @@ -147,11 +147,18 @@ def _add_http_method_to_oas( ) -def generate_oas(apps: List[Application]) -> dict: +def generate_oas(apps: List[Application], version_spec: Optional[str] = None, title_spec: Optional[str] = None) -> dict: """ Generate and return Open Api Specification from PydanticView in application. """ oas = OpenApiSpec3() + + if version_spec is not None: + oas.info.version = version_spec + + if title_spec is not None: + oas.info.title = title_spec + for app in apps: for resources in app.router.resources(): for resource_route in resources: @@ -175,7 +182,9 @@ async def get_oas(request): View to generate the Open Api Specification from PydanticView in application. """ apps = request.app["apps to expose"] - return json_response(generate_oas(apps)) + version_spec = request.app["version_spec"] + title_spec = request.app["title_spec"] + return json_response(generate_oas(apps, version_spec, title_spec)) async def oas_ui(request): diff --git a/demo/main.py b/demo/main.py index ee4902a..0ba8414 100644 --- a/demo/main.py +++ b/demo/main.py @@ -15,7 +15,7 @@ async def pet_not_found_to_404(request, handler): app = Application(middlewares=[pet_not_found_to_404]) -oas.setup(app) +oas.setup(app, version_spec="1.0.1", title_spec="My App") app["model"] = Model() app.router.add_view("/pets", PetCollectionView) diff --git a/tests/test_oas/test_cmd/test_cmd.py b/tests/test_oas/test_cmd/test_cmd.py index c74e3ee..bd50f27 100644 --- a/tests/test_oas/test_cmd/test_cmd.py +++ b/tests/test_oas/test_cmd/test_cmd.py @@ -22,8 +22,12 @@ def test_show_oas_of_app(cmd_line): args.func(args) expected = dedent( - """ - { + """ + { + "info": { + "title": "Aiohttp pydantic application", + "version": "1.0.0" + }, "openapi": "3.0.0", "paths": { "/route-1/{a}": { @@ -69,8 +73,12 @@ def test_show_oas_of_sub_app(cmd_line): args.output = StringIO() args.func(args) expected = dedent( - """ - { + """ + { + "info": { + "title": "Aiohttp pydantic application", + "version": "1.0.0" + }, "openapi": "3.0.0", "paths": { "/sub-app/route-2/{b}": { @@ -110,7 +118,7 @@ def test_show_oas_of_a_callable(cmd_line): """ { "info": { - "title": "MyApp", + "title": "Aiohttp pydantic application", "version": "1.0.0" }, "openapi": "3.0.0", diff --git a/tests/test_oas/test_struct/test_info.py b/tests/test_oas/test_struct/test_info.py index bcd1bf6..937782c 100644 --- a/tests/test_oas/test_struct/test_info.py +++ b/tests/test_oas/test_struct/test_info.py @@ -5,10 +5,16 @@ from aiohttp_pydantic.oas.struct import OpenApiSpec3 def test_info_title(): oas = OpenApiSpec3() - assert oas.info.title is None + assert oas.info.title == "Aiohttp pydantic application" oas.info.title = "Info Title" assert oas.info.title == "Info Title" - assert oas.spec == {"info": {"title": "Info Title"}, "openapi": "3.0.0"} + assert oas.spec == { + "info": { + "title": "Info Title", + "version": "1.0.0", + }, + "openapi": "3.0.0", + } def test_info_description(): @@ -16,15 +22,22 @@ def test_info_description(): assert oas.info.description is None oas.info.description = "info description" assert oas.info.description == "info description" - assert oas.spec == {"info": {"description": "info description"}, "openapi": "3.0.0"} + assert oas.spec == { + "info": { + "description": "info description", + "title": "Aiohttp pydantic application", + "version": "1.0.0", + }, + "openapi": "3.0.0", + } def test_info_version(): oas = OpenApiSpec3() - assert oas.info.version is None + assert oas.info.version == "1.0.0" oas.info.version = "3.14" assert oas.info.version == "3.14" - assert oas.spec == {"info": {"version": "3.14"}, "openapi": "3.0.0"} + assert oas.spec == {"info": {"version": "3.14", "title": "Aiohttp pydantic application"}, "openapi": "3.0.0"} def test_info_terms_of_service(): @@ -33,7 +46,11 @@ def test_info_terms_of_service(): oas.info.terms_of_service = "http://example.com/terms/" assert oas.info.terms_of_service == "http://example.com/terms/" assert oas.spec == { - "info": {"termsOfService": "http://example.com/terms/"}, + "info": { + "title": "Aiohttp pydantic application", + "version": "1.0.0", + "termsOfService": "http://example.com/terms/", + }, "openapi": "3.0.0", } diff --git a/tests/test_oas/test_struct/test_paths.py b/tests/test_oas/test_struct/test_paths.py index 7db09f3..f321a62 100644 --- a/tests/test_oas/test_struct/test_paths.py +++ b/tests/test_oas/test_struct/test_paths.py @@ -6,6 +6,7 @@ def test_paths_description(): oas.paths["/users/{id}"].description = "This route ..." assert oas.spec == { "openapi": "3.0.0", + "info": {"title": "Aiohttp pydantic application", "version": "1.0.0"}, "paths": {"/users/{id}": {"description": "This route ..."}}, } @@ -13,7 +14,11 @@ def test_paths_description(): def test_paths_get(): oas = OpenApiSpec3() oas.paths["/users/{id}"].get - assert oas.spec == {"openapi": "3.0.0", "paths": {"/users/{id}": {"get": {}}}} + assert oas.spec == { + "openapi": "3.0.0", + "info": {"title": "Aiohttp pydantic application", "version": "1.0.0"}, + "paths": {"/users/{id}": {"get": {}}}, + } def test_paths_operation_description(): @@ -22,6 +27,7 @@ def test_paths_operation_description(): operation.description = "Long descriptions ..." assert oas.spec == { "openapi": "3.0.0", + "info": {"title": "Aiohttp pydantic application", "version": "1.0.0"}, "paths": {"/users/{id}": {"get": {"description": "Long descriptions ..."}}}, } @@ -32,6 +38,7 @@ def test_paths_operation_summary(): operation.summary = "Updates a pet in the store with form data" assert oas.spec == { "openapi": "3.0.0", + "info": {"title": "Aiohttp pydantic application", "version": "1.0.0"}, "paths": { "/users/{id}": { "get": {"summary": "Updates a pet in the store with form data"} @@ -51,6 +58,7 @@ def test_paths_operation_parameters(): assert oas.spec == { "openapi": "3.0.0", + "info": {"title": "Aiohttp pydantic application", "version": "1.0.0"}, "paths": { "/users/{petId}": { "get": { @@ -86,6 +94,7 @@ def test_paths_operation_requestBody(): request_body.required = True assert oas.spec == { "openapi": "3.0.0", + "info": {"title": "Aiohttp pydantic application", "version": "1.0.0"}, "paths": { "/users/{petId}": { "get": { diff --git a/tests/test_oas/test_struct/test_servers.py b/tests/test_oas/test_struct/test_servers.py index b8e0406..d2bb1a4 100644 --- a/tests/test_oas/test_struct/test_servers.py +++ b/tests/test_oas/test_struct/test_servers.py @@ -9,6 +9,7 @@ def test_sever_url(): oas.servers[1].url = "https://development.gigantic-server.com/v2" assert oas.spec == { "openapi": "3.0.0", + "info": {"title": "Aiohttp pydantic application", "version": "1.0.0"}, "servers": [ {"url": "https://development.gigantic-server.com/v1"}, {"url": "https://development.gigantic-server.com/v2"}, @@ -22,6 +23,7 @@ def test_sever_description(): oas.servers[0].description = "Development server" assert oas.spec == { "openapi": "3.0.0", + "info": {"title": "Aiohttp pydantic application", "version": "1.0.0"}, "servers": [ { "url": "https://development.gigantic-server.com/v1", diff --git a/tests/test_oas/test_view.py b/tests/test_oas/test_view.py index adf653f..364e8eb 100644 --- a/tests/test_oas/test_view.py +++ b/tests/test_oas/test_view.py @@ -8,6 +8,7 @@ from pydantic.main import BaseModel from aiohttp_pydantic import PydanticView, oas from aiohttp_pydantic.oas.typing import r200, r201, r204, r404 +from aiohttp_pydantic.oas.view import generate_oas class Color(str, Enum): @@ -316,3 +317,23 @@ async def test_simple_type_route_should_have_get_method(generated_oas): } }, } + +async def test_generated_view_info_default(): + apps = (web.Application(),) + spec = generate_oas(apps) + + assert spec == {'info': {'title': 'Aiohttp pydantic application', 'version': '1.0.0'}, 'openapi': '3.0.0'} + + +async def test_generated_view_info_as_version(): + apps = (web.Application(),) + spec = generate_oas(apps, version_spec="test version") + + assert spec == {'info': {'title': 'Aiohttp pydantic application', 'version': 'test version'}, 'openapi': '3.0.0'} + + +async def test_generated_view_info_as_title(): + apps = (web.Application(),) + spec = generate_oas(apps, title_spec="test title") + + assert spec == {'info': {'title': 'test title', 'version': '1.0.0'}, 'openapi': '3.0.0'} From 324c9b02f3f9f3465a12aaacc9b2ce2ffd04d634 Mon Sep 17 00:00:00 2001 From: Vincent Maillol Date: Sun, 4 Apr 2021 14:02:37 +0200 Subject: [PATCH 09/26] Update version 1.9.0 --- README.rst | 10 ++++++++++ aiohttp_pydantic/__init__.py | 2 +- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/README.rst b/README.rst index a0a2f43..4e25f2e 100644 --- a/README.rst +++ b/README.rst @@ -225,6 +225,16 @@ on the same route, you must use *apps_to_expose* parameter. oas.setup(app, apps_to_expose=[sub_app_1, sub_app_2]) + +You can change the title or the version of the generated open api specification using +*title_spec* and *version_spec* parameters: + + +.. code-block:: python3 + + oas.setup(app, title_spec="My application", version_spec="1.2.3") + + Add annotation to define response content ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/aiohttp_pydantic/__init__.py b/aiohttp_pydantic/__init__.py index b8a6eec..9604bdd 100644 --- a/aiohttp_pydantic/__init__.py +++ b/aiohttp_pydantic/__init__.py @@ -1,5 +1,5 @@ from .view import PydanticView -__version__ = "1.8.1" +__version__ = "1.9.0" __all__ = ("PydanticView", "__version__") From 5f86e1efdaac5ef5d2852e66f2ee65f01ebb67d8 Mon Sep 17 00:00:00 2001 From: Daan de Ruiter <30779179+drderuiter@users.noreply.github.com> Date: Thu, 13 May 2021 10:59:01 +0200 Subject: [PATCH 10/26] Improve compatibility with web.View and support subclassing Views --- aiohttp_pydantic/view.py | 34 ++++++++++++++++------- tests/test_inheritance.py | 58 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 82 insertions(+), 10 deletions(-) create mode 100644 tests/test_inheritance.py diff --git a/aiohttp_pydantic/view.py b/aiohttp_pydantic/view.py index 3030c3b..42b3664 100644 --- a/aiohttp_pydantic/view.py +++ b/aiohttp_pydantic/view.py @@ -24,28 +24,42 @@ class PydanticView(AbstractView): An AIOHTTP View that validate request using function annotations. """ + # Allowed HTTP methods; overridden when subclassed. + allowed_methods: set = {} + async def _iter(self) -> StreamResponse: - method = getattr(self, self.request.method.lower(), None) - resp = await method() - return resp + if (method_name := self.request.method) not in self.allowed_methods: + self._raise_allowed_methods() + return await getattr(self, method_name.lower())() def __await__(self) -> Generator[Any, None, StreamResponse]: return self._iter().__await__() - def __init_subclass__(cls, **kwargs): + def __init_subclass__(cls, **kwargs) -> None: + """Define allowed methods and decorate handlers. + + Handlers are decorated if and only if they meet the following conditions: + - the handler corresponds to an allowed method; + - the handler method was not inherited from a :class:`PydanticView` base + class. This prevents that methods are decorated multiple times. + """ cls.allowed_methods = { meth_name for meth_name in METH_ALL if hasattr(cls, meth_name.lower()) } for meth_name in METH_ALL: - if meth_name not in cls.allowed_methods: - setattr(cls, meth_name.lower(), cls.raise_not_allowed) - else: + if meth_name in cls.allowed_methods: handler = getattr(cls, meth_name.lower()) - decorated_handler = inject_params(handler, cls.parse_func_signature) - setattr(cls, meth_name.lower(), decorated_handler) + for base_class in cls.__bases__: + if is_pydantic_view(base_class): + parent_handler = getattr(base_class, meth_name.lower(), None) + if handler == parent_handler: + break + else: + decorated_handler = inject_params(handler, cls.parse_func_signature) + setattr(cls, meth_name.lower(), decorated_handler) - async def raise_not_allowed(self): + def _raise_allowed_methods(self) -> None: raise HTTPMethodNotAllowed(self.request.method, self.allowed_methods) @staticmethod diff --git a/tests/test_inheritance.py b/tests/test_inheritance.py new file mode 100644 index 0000000..0375c01 --- /dev/null +++ b/tests/test_inheritance.py @@ -0,0 +1,58 @@ +from typing import Any + +from aiohttp_pydantic import PydanticView + + +def count_wrappers(obj: Any) -> int: + """Count the number of times that an object is wrapped.""" + i = 0 + while i < 10: + try: + obj = obj.__wrapped__ + except AttributeError: + return i + else: + i += 1 + raise RuntimeError("Too many wrappers") + + +class ViewParent(PydanticView): + async def put(self): + pass + + async def delete(self): + pass + + +class ViewParentNonPydantic: + async def post(self): + pass + + +class ViewChild(ViewParent, ViewParentNonPydantic): + async def get(self): + pass + + async def delete(self): + pass + + async def not_allowed(self): + pass + + +def test_allowed_methods_are_set_correctly(): + assert ViewParent.allowed_methods == {"PUT", "DELETE"} + assert ViewChild.allowed_methods == {"GET", "POST", "PUT", "DELETE"} + + +def test_allowed_methods_get_decorated_exactly_once(): + assert count_wrappers(ViewParent.put) == 1 + assert count_wrappers(ViewParent.delete) == 1 + assert count_wrappers(ViewChild.get) == 1 + assert count_wrappers(ViewChild.post) == 1 + assert count_wrappers(ViewChild.put) == 1 + assert count_wrappers(ViewChild.post) == 1 + assert count_wrappers(ViewChild.put) == 1 + + assert count_wrappers(ViewChild.not_allowed) == 0 + assert count_wrappers(ViewParentNonPydantic.post) == 0 From c92437c624e000edb3d09c66facad62f052409fd Mon Sep 17 00:00:00 2001 From: Daan de Ruiter <30779179+drderuiter@users.noreply.github.com> Date: Thu, 13 May 2021 11:06:54 +0200 Subject: [PATCH 11/26] Further specify allowed_methods type hint --- aiohttp_pydantic/view.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/aiohttp_pydantic/view.py b/aiohttp_pydantic/view.py index 42b3664..a4f1c59 100644 --- a/aiohttp_pydantic/view.py +++ b/aiohttp_pydantic/view.py @@ -1,6 +1,6 @@ from functools import update_wrapper from inspect import iscoroutinefunction -from typing import Any, Callable, Generator, Iterable +from typing import Any, Callable, Generator, Iterable, Set from aiohttp.abc import AbstractView from aiohttp.hdrs import METH_ALL @@ -25,7 +25,7 @@ class PydanticView(AbstractView): """ # Allowed HTTP methods; overridden when subclassed. - allowed_methods: set = {} + allowed_methods: Set[str] = {} async def _iter(self) -> StreamResponse: if (method_name := self.request.method) not in self.allowed_methods: From 08ab4d2610ea30a63c6202d6e89cadabc3e7fad6 Mon Sep 17 00:00:00 2001 From: Vincent Maillol Date: Sat, 10 Jul 2021 08:16:27 +0200 Subject: [PATCH 12/26] refactoring --- aiohttp_pydantic/view.py | 31 +++++++-------- tests/test_inheritance.py | 80 +++++++++++++++++++++++---------------- 2 files changed, 64 insertions(+), 47 deletions(-) diff --git a/aiohttp_pydantic/view.py b/aiohttp_pydantic/view.py index a4f1c59..f75cb9d 100644 --- a/aiohttp_pydantic/view.py +++ b/aiohttp_pydantic/view.py @@ -1,6 +1,7 @@ from functools import update_wrapper from inspect import iscoroutinefunction -from typing import Any, Callable, Generator, Iterable, Set +from typing import Any, Callable, Generator, Iterable, Set, ClassVar +import warnings from aiohttp.abc import AbstractView from aiohttp.hdrs import METH_ALL @@ -25,7 +26,7 @@ class PydanticView(AbstractView): """ # Allowed HTTP methods; overridden when subclassed. - allowed_methods: Set[str] = {} + allowed_methods: ClassVar[Set[str]] = {} async def _iter(self) -> StreamResponse: if (method_name := self.request.method) not in self.allowed_methods: @@ -38,30 +39,30 @@ class PydanticView(AbstractView): def __init_subclass__(cls, **kwargs) -> None: """Define allowed methods and decorate handlers. - Handlers are decorated if and only if they meet the following conditions: - - the handler corresponds to an allowed method; - - the handler method was not inherited from a :class:`PydanticView` base - class. This prevents that methods are decorated multiple times. + Handlers are decorated if and only if they directly bound on the PydanticView class or + PydanticView subclass. This prevents that methods are decorated multiple times and that method + defined in aiohttp.View parent class is decorated. """ + cls.allowed_methods = { meth_name for meth_name in METH_ALL if hasattr(cls, meth_name.lower()) } for meth_name in METH_ALL: - if meth_name in cls.allowed_methods: + if meth_name.lower() in vars(cls): handler = getattr(cls, meth_name.lower()) - for base_class in cls.__bases__: - if is_pydantic_view(base_class): - parent_handler = getattr(base_class, meth_name.lower(), None) - if handler == parent_handler: - break - else: - decorated_handler = inject_params(handler, cls.parse_func_signature) - setattr(cls, meth_name.lower(), decorated_handler) + decorated_handler = inject_params(handler, cls.parse_func_signature) + setattr(cls, meth_name.lower(), decorated_handler) def _raise_allowed_methods(self) -> None: raise HTTPMethodNotAllowed(self.request.method, self.allowed_methods) + def raise_not_allowed(self) -> None: + warnings.warn( + "PydanticView.raise_not_allowed is deprecated and renamed _raise_allowed_methods", + DeprecationWarning, stacklevel=2) + self._raise_allowed_methods() + @staticmethod def parse_func_signature(func: Callable) -> Iterable[AbstractInjector]: path_args, body_args, qs_args, header_args, defaults = _parse_func_signature( diff --git a/tests/test_inheritance.py b/tests/test_inheritance.py index 0375c01..e6214a1 100644 --- a/tests/test_inheritance.py +++ b/tests/test_inheritance.py @@ -1,6 +1,7 @@ from typing import Any from aiohttp_pydantic import PydanticView +from aiohttp.web import View def count_wrappers(obj: Any) -> int: @@ -16,43 +17,58 @@ def count_wrappers(obj: Any) -> int: raise RuntimeError("Too many wrappers") -class ViewParent(PydanticView): +class AiohttpViewParent(View): async def put(self): pass - async def delete(self): + +class PydanticViewParent(PydanticView): + async def get(self, id: int, /): pass -class ViewParentNonPydantic: - async def post(self): - pass - - -class ViewChild(ViewParent, ViewParentNonPydantic): - async def get(self): - pass - - async def delete(self): - pass - - async def not_allowed(self): - pass - - -def test_allowed_methods_are_set_correctly(): - assert ViewParent.allowed_methods == {"PUT", "DELETE"} - assert ViewChild.allowed_methods == {"GET", "POST", "PUT", "DELETE"} - - def test_allowed_methods_get_decorated_exactly_once(): - assert count_wrappers(ViewParent.put) == 1 - assert count_wrappers(ViewParent.delete) == 1 - assert count_wrappers(ViewChild.get) == 1 - assert count_wrappers(ViewChild.post) == 1 - assert count_wrappers(ViewChild.put) == 1 - assert count_wrappers(ViewChild.post) == 1 - assert count_wrappers(ViewChild.put) == 1 - assert count_wrappers(ViewChild.not_allowed) == 0 - assert count_wrappers(ViewParentNonPydantic.post) == 0 + class ChildView(PydanticViewParent): + async def post(self, id: int, /): + pass + + class SubChildView(ChildView): + async def get(self, id: int, /): + return super().get(id) + + assert count_wrappers(ChildView.post) == 1 + assert count_wrappers(ChildView.get) == 1 + assert count_wrappers(SubChildView.post) == 1 + assert count_wrappers(SubChildView.get) == 1 + + +def test_methods_inherited_from_aiohttp_view_should_not_be_decorated(): + + class ChildView(AiohttpViewParent, PydanticView): + async def post(self, id: int, /): + pass + + assert count_wrappers(ChildView.put) == 0 + assert count_wrappers(ChildView.post) == 1 + + +def test_allowed_methods_are_set_correctly(): + + class ChildView(AiohttpViewParent, PydanticView): + async def post(self, id: int, /): + pass + + assert ChildView.allowed_methods == {"POST", "PUT"} + + class ChildView(PydanticViewParent): + async def post(self, id: int, /): + pass + + assert ChildView.allowed_methods == {"POST", "GET"} + + class ChildView(AiohttpViewParent, PydanticViewParent): + async def post(self, id: int, /): + pass + + assert ChildView.allowed_methods == {"POST", "PUT", "GET"} From 89a22f2fcdd77c1f6551dfad9c023f2360809524 Mon Sep 17 00:00:00 2001 From: Vincent Maillol Date: Sun, 11 Jul 2021 07:31:37 +0200 Subject: [PATCH 13/26] code reformatting --- aiohttp_pydantic/__init__.py | 2 +- aiohttp_pydantic/oas/struct.py | 5 ++++- aiohttp_pydantic/oas/view.py | 6 +++++- aiohttp_pydantic/view.py | 4 +++- tests/test_inheritance.py | 3 --- tests/test_oas/test_cmd/test_cmd.py | 4 ++-- tests/test_oas/test_struct/test_info.py | 5 ++++- tests/test_oas/test_view.py | 16 +++++++++++++--- tests/test_validation_query_string.py | 2 +- 9 files changed, 33 insertions(+), 14 deletions(-) diff --git a/aiohttp_pydantic/__init__.py b/aiohttp_pydantic/__init__.py index 9604bdd..ac08759 100644 --- a/aiohttp_pydantic/__init__.py +++ b/aiohttp_pydantic/__init__.py @@ -1,5 +1,5 @@ from .view import PydanticView -__version__ = "1.9.0" +__version__ = "1.9.1" __all__ = ("PydanticView", "__version__") diff --git a/aiohttp_pydantic/oas/struct.py b/aiohttp_pydantic/oas/struct.py index 576079a..cf01e9c 100644 --- a/aiohttp_pydantic/oas/struct.py +++ b/aiohttp_pydantic/oas/struct.py @@ -305,7 +305,10 @@ class Components: class OpenApiSpec3: def __init__(self): - self._spec = {"openapi": "3.0.0", "info": {"version": "1.0.0", "title": "Aiohttp pydantic application"}} + self._spec = { + "openapi": "3.0.0", + "info": {"version": "1.0.0", "title": "Aiohttp pydantic application"}, + } @property def info(self) -> Info: diff --git a/aiohttp_pydantic/oas/view.py b/aiohttp_pydantic/oas/view.py index d91bb8e..b2fc9e5 100644 --- a/aiohttp_pydantic/oas/view.py +++ b/aiohttp_pydantic/oas/view.py @@ -147,7 +147,11 @@ def _add_http_method_to_oas( ) -def generate_oas(apps: List[Application], version_spec: Optional[str] = None, title_spec: Optional[str] = None) -> dict: +def generate_oas( + apps: List[Application], + version_spec: Optional[str] = None, + title_spec: Optional[str] = None, +) -> dict: """ Generate and return Open Api Specification from PydanticView in application. """ diff --git a/aiohttp_pydantic/view.py b/aiohttp_pydantic/view.py index f75cb9d..6196ddd 100644 --- a/aiohttp_pydantic/view.py +++ b/aiohttp_pydantic/view.py @@ -60,7 +60,9 @@ class PydanticView(AbstractView): def raise_not_allowed(self) -> None: warnings.warn( "PydanticView.raise_not_allowed is deprecated and renamed _raise_allowed_methods", - DeprecationWarning, stacklevel=2) + DeprecationWarning, + stacklevel=2, + ) self._raise_allowed_methods() @staticmethod diff --git a/tests/test_inheritance.py b/tests/test_inheritance.py index e6214a1..d759c0d 100644 --- a/tests/test_inheritance.py +++ b/tests/test_inheritance.py @@ -28,7 +28,6 @@ class PydanticViewParent(PydanticView): def test_allowed_methods_get_decorated_exactly_once(): - class ChildView(PydanticViewParent): async def post(self, id: int, /): pass @@ -44,7 +43,6 @@ def test_allowed_methods_get_decorated_exactly_once(): def test_methods_inherited_from_aiohttp_view_should_not_be_decorated(): - class ChildView(AiohttpViewParent, PydanticView): async def post(self, id: int, /): pass @@ -54,7 +52,6 @@ def test_methods_inherited_from_aiohttp_view_should_not_be_decorated(): def test_allowed_methods_are_set_correctly(): - class ChildView(AiohttpViewParent, PydanticView): async def post(self, id: int, /): pass diff --git a/tests/test_oas/test_cmd/test_cmd.py b/tests/test_oas/test_cmd/test_cmd.py index bd50f27..f9d78ca 100644 --- a/tests/test_oas/test_cmd/test_cmd.py +++ b/tests/test_oas/test_cmd/test_cmd.py @@ -22,7 +22,7 @@ def test_show_oas_of_app(cmd_line): args.func(args) expected = dedent( - """ + """ { "info": { "title": "Aiohttp pydantic application", @@ -73,7 +73,7 @@ def test_show_oas_of_sub_app(cmd_line): args.output = StringIO() args.func(args) expected = dedent( - """ + """ { "info": { "title": "Aiohttp pydantic application", diff --git a/tests/test_oas/test_struct/test_info.py b/tests/test_oas/test_struct/test_info.py index 937782c..642072b 100644 --- a/tests/test_oas/test_struct/test_info.py +++ b/tests/test_oas/test_struct/test_info.py @@ -37,7 +37,10 @@ def test_info_version(): assert oas.info.version == "1.0.0" oas.info.version = "3.14" assert oas.info.version == "3.14" - assert oas.spec == {"info": {"version": "3.14", "title": "Aiohttp pydantic application"}, "openapi": "3.0.0"} + assert oas.spec == { + "info": {"version": "3.14", "title": "Aiohttp pydantic application"}, + "openapi": "3.0.0", + } def test_info_terms_of_service(): diff --git a/tests/test_oas/test_view.py b/tests/test_oas/test_view.py index 364e8eb..b95c60b 100644 --- a/tests/test_oas/test_view.py +++ b/tests/test_oas/test_view.py @@ -318,22 +318,32 @@ async def test_simple_type_route_should_have_get_method(generated_oas): }, } + async def test_generated_view_info_default(): apps = (web.Application(),) spec = generate_oas(apps) - assert spec == {'info': {'title': 'Aiohttp pydantic application', 'version': '1.0.0'}, 'openapi': '3.0.0'} + assert spec == { + "info": {"title": "Aiohttp pydantic application", "version": "1.0.0"}, + "openapi": "3.0.0", + } async def test_generated_view_info_as_version(): apps = (web.Application(),) spec = generate_oas(apps, version_spec="test version") - assert spec == {'info': {'title': 'Aiohttp pydantic application', 'version': 'test version'}, 'openapi': '3.0.0'} + assert spec == { + "info": {"title": "Aiohttp pydantic application", "version": "test version"}, + "openapi": "3.0.0", + } async def test_generated_view_info_as_title(): apps = (web.Application(),) spec = generate_oas(apps, title_spec="test title") - assert spec == {'info': {'title': 'test title', 'version': '1.0.0'}, 'openapi': '3.0.0'} + assert spec == { + "info": {"title": "test title", "version": "1.0.0"}, + "openapi": "3.0.0", + } diff --git a/tests/test_validation_query_string.py b/tests/test_validation_query_string.py index 57d886e..4b8913f 100644 --- a/tests/test_validation_query_string.py +++ b/tests/test_validation_query_string.py @@ -11,7 +11,7 @@ class ArticleView(PydanticView): with_comments: bool, age: Optional[int] = None, nb_items: int = 7, - tags: List[str] = Field(default_factory=list) + tags: List[str] = Field(default_factory=list), ): return web.json_response( { From ff32f68e897cc0635a8fa2f7fbdb857adc533709 Mon Sep 17 00:00:00 2001 From: Vincent Maillol Date: Mon, 12 Jul 2021 08:31:08 +0200 Subject: [PATCH 14/26] move ci --- .drone.jsonnet | 76 +++++++++++++++++++++++++++++++++ .drone.yml | 99 +++++++++++++++++++++++++++++++++++++++++++ .travis.yml | 24 ----------- README.rst | 5 ++- requirements/ci.txt | 32 +++++++++++--- requirements/test.txt | 12 +++--- setup.cfg | 2 +- 7 files changed, 211 insertions(+), 39 deletions(-) create mode 100644 .drone.jsonnet create mode 100644 .drone.yml delete mode 100644 .travis.yml diff --git a/.drone.jsonnet b/.drone.jsonnet new file mode 100644 index 0000000..cfeb257 --- /dev/null +++ b/.drone.jsonnet @@ -0,0 +1,76 @@ +/* +Code to generate the .drone.yaml. Use the command: + +drone jsonnet --stream --format yaml +*/ + + +local PYTHON_VERSIONS = ["3.8", "3.9"]; + + +local BuildAndTestPipeline(name, image) = { + kind: "pipeline", + type: "docker", + name: name, + steps: [ + { + name: "Install package and test", + image: image, + commands: [ + "echo Install package", + "pip install -U setuptools wheel pip; pip install .", + "echo Test to import module of package", + "python -c \"import importlib, setuptools; [print(importlib.import_module(package).__name__, '[OK]') for package in setuptools.find_packages() if package.startswith('aiohttp_pydantic.') or package == 'aiohttp_pydantic']\"", + "echo Install CI dependencies", + "pip install -r requirements/ci.txt", + "echo Launch unittest", + "pytest --cov-report=xml --cov=aiohttp_pydantic tests/", + "echo Check the README.rst render", + "python -m readme_renderer -o /dev/null README.rst" + ] + }, + { + name: "coverage", + image: "plugins/codecov", + settings: { + token: "9ea10e04-a71a-4eea-9dcc-8eaabe1479e2", + files: ["coverage.xml"] + } + } + ], + trigger: { + event: ["push", "tag"] + } +}; + + +[ + BuildAndTestPipeline("python-" + std.strReplace(pythonVersion, '.', '-'), + "python:" + pythonVersion) + for pythonVersion in PYTHON_VERSIONS +] + [ + { + kind: "pipeline", + type: "docker", + name: "Deploy on Pypi", + steps: [ + { + name: "Deploy on Pypi", + image: "plugins/pypi", + settings: { + username: { + from_secret: 'pypi_username' + }, + password: { + from_secret: 'pypi_password' + } + }, + distributions: 'bdist_wheel' + }, + ], + trigger: { + event: ["tag"] + }, + depends_on: ["python-" + std.strReplace(pythonVersion, '.', '-') for pythonVersion in PYTHON_VERSIONS] + } +] diff --git a/.drone.yml b/.drone.yml new file mode 100644 index 0000000..22c1581 --- /dev/null +++ b/.drone.yml @@ -0,0 +1,99 @@ +--- +kind: pipeline +type: docker +name: python-3-8 + +platform: + os: linux + arch: amd64 + +steps: +- name: Install package and test + image: python:3.8 + commands: + - echo Install package + - pip install -U setuptools wheel pip; pip install . + - echo Test to import module of package + - python -c "import importlib, setuptools; [print(importlib.import_module(package).__name__, '[OK]') for package in setuptools.find_packages() if package.startswith('aiohttp_pydantic.') or package == 'aiohttp_pydantic']" + - echo Install CI dependencies + - pip install -r requirements/ci.txt + - echo Launch unittest + - pytest --cov-report=xml --cov=aiohttp_pydantic tests/ + - echo Check the README.rst render + - python -m readme_renderer -o /dev/null README.rst + +- name: coverage + image: plugins/codecov + settings: + files: + - coverage.xml + token: 9ea10e04-a71a-4eea-9dcc-8eaabe1479e2 + +trigger: + event: + - push + - tag + +--- +kind: pipeline +type: docker +name: python-3-9 + +platform: + os: linux + arch: amd64 + +steps: +- name: Install package and test + image: python:3.9 + commands: + - echo Install package + - pip install -U setuptools wheel pip; pip install . + - echo Test to import module of package + - python -c "import importlib, setuptools; [print(importlib.import_module(package).__name__, '[OK]') for package in setuptools.find_packages() if package.startswith('aiohttp_pydantic.') or package == 'aiohttp_pydantic']" + - echo Install CI dependencies + - pip install -r requirements/ci.txt + - echo Launch unittest + - pytest --cov-report=xml --cov=aiohttp_pydantic tests/ + - echo Check the README.rst render + - python -m readme_renderer -o /dev/null README.rst + +- name: coverage + image: plugins/codecov + settings: + files: + - coverage.xml + token: 9ea10e04-a71a-4eea-9dcc-8eaabe1479e2 + +trigger: + event: + - push + - tag + +--- +kind: pipeline +type: docker +name: Deploy on Pypi + +platform: + os: linux + arch: amd64 + +steps: +- name: Deploy on Pypi + image: plugins/pypi + settings: + password: + from_secret: pypi_password + username: + from_secret: pypi_username + +trigger: + event: + - tag + +depends_on: +- python-3-8 +- python-3-9 + +... diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index f32b871..0000000 --- a/.travis.yml +++ /dev/null @@ -1,24 +0,0 @@ -language: python -python: -- '3.8' -script: -- pytest --cov-report=xml --cov=aiohttp_pydantic tests/ -- sh -c 'python -m readme_renderer README.rst > /dev/null' -install: -- pip install -U setuptools wheel pip -- pip install -r requirements/test.txt -- pip install -r requirements/ci.txt -- pip install . -after_success: - - codecov -deploy: - provider: pypi - twine_check: true - username: __token__ - password: - secure: ki81Limjj8UgsX1GNpOF2+vYjc6GEPY1V9BbJkQl+5WVTynqKTDEi+jekx8Id0jYEGGQ8/PfTiXe7dY/MqfQ0oWQ5+UNmGZIQJwYCft4FJWrI5QoL1LE0tqKpXCzBX7rGr1BOdvToS9zwf3RDr1u7ib16V/xakX55raVpQ37ttE0cKEPzvq6MqZTfYvq0VnhPmTDbTDBd9krHHAAG5lVhm9oAbp9TkhKsWDuA+wGzgKt2tuPX6+Le4op/wiiBhAnhvcVzjDWaX8dxd3Ac0XlnPtl8EMe5lJJez/ahGedydwGDJC75TOl1b7WP9AqogvNISVN+2VYUVxkgoK9yC9zEjhCSWKHSz+t8ZddB+itYHvj9lMf04iObq8OSUcD71R4rASWMZ89YdksWb6qvD+md1oEl/M6JSyZAkv+aedFL5iyKS4oJpZT3fYYloUqhF3/aDVgC3mlnXVsxC2cCIdpvu2EVjpFqFJ+9qGpp3ZlhRfDkjbQA0IA6KXKaWkIadQouJ4Wr1WtXjN4w0QlAvGV/q3m4bQ3ZZGxYipS9MQwDnUoRYtrX6j7bsaXjBdfhPNlwzgHQDPbD//oX9ZI1Oe6+kT/WKQvBrtvftv+TUhQ49uePHn5o/eYAKh35IwYTBxLgk2t483k0ZI5cjVXd2zGRgAxPdB/XyGW84dJGPJNn8o= - distributions: "bdist_wheel" - on: - tags: true - branch: main - python: '3.8' diff --git a/README.rst b/README.rst index 4e25f2e..b30ab07 100644 --- a/README.rst +++ b/README.rst @@ -1,8 +1,9 @@ Aiohttp pydantic - Aiohttp View to validate and parse request ============================================================= -.. image:: https://travis-ci.org/Maillol/aiohttp-pydantic.svg?branch=main - :target: https://travis-ci.org/Maillol/aiohttp-pydantic +.. image:: https://cloud.drone.io/api/badges/Maillol/aiohttp-pydantic/status.svg + :target: https://cloud.drone.io/Maillol/aiohttp-pydantic + :alt: Build status for master branch .. image:: https://img.shields.io/pypi/v/aiohttp-pydantic :target: https://img.shields.io/pypi/v/aiohttp-pydantic diff --git a/requirements/ci.txt b/requirements/ci.txt index 3380aa6..30b16b6 100644 --- a/requirements/ci.txt +++ b/requirements/ci.txt @@ -1,8 +1,28 @@ -certifi==2020.11.8 +aiohttp==3.7.3 +async-timeout==3.0.1 +attrs==21.2.0 +bleach==3.3.0 +certifi==2021.5.30 chardet==3.0.4 -codecov==2.1.10 -coverage==5.3 +codecov==2.1.11 +coverage==5.5 +docutils==0.17.1 idna==2.10 -readme-renderer==26.0 -requests==2.25.0 -urllib3==1.26.2 +iniconfig==1.1.1 +multidict==5.1.0 +packaging==21.0 +pluggy==0.13.1 +py==1.10.0 +Pygments==2.9.0 +pyparsing==2.4.7 +pytest==6.1.2 +pytest-aiohttp==0.3.0 +pytest-cov==2.10.1 +readme-renderer==29.0 +requests==2.25.1 +six==1.16.0 +toml==0.10.2 +typing-extensions==3.10.0.0 +urllib3==1.26.6 +webencodings==0.5.1 +yarl==1.6.3 \ No newline at end of file diff --git a/requirements/test.txt b/requirements/test.txt index 85a1222..cd9319c 100644 --- a/requirements/test.txt +++ b/requirements/test.txt @@ -1,13 +1,13 @@ -attrs==20.3.0 -coverage==5.3 +attrs-21.2.0 +coverage-5.5 iniconfig==1.1.1 -packaging==20.4 +packaging==21.0 pluggy==0.13.1 -py==1.9.0 +py==1.10.0 pyparsing==2.4.7 -pytest==6.1.2 +pytest==6.2.4 pytest-aiohttp==0.3.0 -pytest-cov==2.10.1 +pytest-cov-2.12.1 pyyaml==5.3.1 six==1.15.0 toml==0.10.2 diff --git a/setup.cfg b/setup.cfg index f9d9c96..f6377a4 100644 --- a/setup.cfg +++ b/setup.cfg @@ -36,7 +36,7 @@ install_requires = [options.extras_require] test = pytest==6.1.2; pytest-aiohttp==0.3.0; pytest-cov==2.10.1 -ci = pytest==6.1.2; pytest-aiohttp==0.3.0; pytest-cov==2.10.1; codecov==2.1.10; readme-renderer==26.0 +ci = pytest==6.1.2; pytest-aiohttp==0.3.0; pytest-cov==2.10.1; codecov==2.1.11; readme-renderer==29.0 [options.packages.find] exclude = From c9c8c6e205fadaa168a61fd9a680e7f0e9fc20eb Mon Sep 17 00:00:00 2001 From: Vincent Maillol Date: Fri, 16 Jul 2021 10:52:41 +0200 Subject: [PATCH 15/26] Enable deploy on pypi --- .drone.jsonnet | 19 ++++++++++++------- .drone.yml | 21 ++++++++++++++++----- .gitignore | 2 +- 3 files changed, 29 insertions(+), 13 deletions(-) diff --git a/.drone.jsonnet b/.drone.jsonnet index cfeb257..732497b 100644 --- a/.drone.jsonnet +++ b/.drone.jsonnet @@ -39,7 +39,7 @@ local BuildAndTestPipeline(name, image) = { } ], trigger: { - event: ["push", "tag"] + event: ["pull_request", "push", "tag"] } }; @@ -55,17 +55,22 @@ local BuildAndTestPipeline(name, image) = { name: "Deploy on Pypi", steps: [ { - name: "Deploy on Pypi", - image: "plugins/pypi", - settings: { - username: { + name: "Install twine and deploy", + image: "python3.8", + environment: { + pypi_username: { from_secret: 'pypi_username' }, - password: { + pypi_password: { from_secret: 'pypi_password' } }, - distributions: 'bdist_wheel' + commands: [ + "pip install --force-reinstall twine wheel", + "python setup.py build bdist_wheel", + "set +x", + "twine upload --non-interactive -u \"$pypi_username\" -p \"$pypi_password\" dist/*" + ] }, ], trigger: { diff --git a/.drone.yml b/.drone.yml index 22c1581..cc2ef11 100644 --- a/.drone.yml +++ b/.drone.yml @@ -31,6 +31,7 @@ steps: trigger: event: + - pull_request - push - tag @@ -67,6 +68,7 @@ steps: trigger: event: + - pull_request - push - tag @@ -80,12 +82,17 @@ platform: arch: amd64 steps: -- name: Deploy on Pypi - image: plugins/pypi - settings: - password: +- name: Install twine and deploy + image: python3.8 + commands: + - pip install --force-reinstall twine wheel + - python setup.py build bdist_wheel + - set +x + - twine upload --non-interactive -u "$pypi_username" -p "$pypi_password" dist/* + environment: + pypi_password: from_secret: pypi_password - username: + pypi_username: from_secret: pypi_username trigger: @@ -96,4 +103,8 @@ depends_on: - python-3-8 - python-3-9 +--- +kind: signature +hmac: dfd0429e3b9f364147c56a400cf37466d0cbf0966e613f11b726777553fd9931 + ... diff --git a/.gitignore b/.gitignore index c2d8c31..f6640f4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,9 +1,9 @@ .coverage .idea/ +.pypirc .pytest_cache __pycache__ aiohttp_pydantic.egg-info/ build/ coverage.xml dist/ - From 7ab2d84263835020ce1af2391d7395e71622e5a5 Mon Sep 17 00:00:00 2001 From: Vincent Maillol Date: Mon, 26 Jul 2021 08:12:38 +0200 Subject: [PATCH 16/26] Add a hook to intercept ValidationError --- README.rst | 20 ++++++++++ aiohttp_pydantic/injectors.py | 7 +++- aiohttp_pydantic/view.py | 25 +++++++++--- tests/test_hook_to_custom_response.py | 56 +++++++++++++++++++++++++++ 4 files changed, 100 insertions(+), 8 deletions(-) create mode 100644 tests/test_hook_to_custom_response.py diff --git a/README.rst b/README.rst index b30ab07..6c95003 100644 --- a/README.rst +++ b/README.rst @@ -310,6 +310,26 @@ Open Api Specification. self.request.app["model"].remove_pet(id) return web.Response(status=204) + +Custom Validation error +----------------------- + +You can redefine the on_validation_error hook in your PydanticView + +.. code-block:: python3 + + class PetView(PydanticView): + + async def on_validation_error(self, + exception: ValidationError, + context: str): + errors = exception.errors() + for error in errors: + error["in"] = context # context is "body", "headers", "path" or "query string" + error["custom"] = "your custom field ..." + return json_response(data=errors, status=400) + + Demo ---- diff --git a/aiohttp_pydantic/injectors.py b/aiohttp_pydantic/injectors.py index 1f5ae24..8147c4c 100644 --- a/aiohttp_pydantic/injectors.py +++ b/aiohttp_pydantic/injectors.py @@ -2,7 +2,7 @@ import abc import typing from inspect import signature from json.decoder import JSONDecodeError -from typing import Callable, Tuple +from typing import Callable, Tuple, Literal from aiohttp.web_exceptions import HTTPBadRequest from aiohttp.web_request import BaseRequest @@ -12,6 +12,9 @@ from pydantic import BaseModel from .utils import is_pydantic_base_model +CONTEXT = Literal["body", "headers", "path", "query string"] + + class AbstractInjector(metaclass=abc.ABCMeta): """ An injector parse HTTP request and inject params to the view. @@ -19,7 +22,7 @@ class AbstractInjector(metaclass=abc.ABCMeta): @property @abc.abstractmethod - def context(self) -> str: + def context(self) -> CONTEXT: """ The name of part of parsed request i.e "HTTP header", "URL path", ... diff --git a/aiohttp_pydantic/view.py b/aiohttp_pydantic/view.py index 6196ddd..355def5 100644 --- a/aiohttp_pydantic/view.py +++ b/aiohttp_pydantic/view.py @@ -1,6 +1,6 @@ from functools import update_wrapper from inspect import iscoroutinefunction -from typing import Any, Callable, Generator, Iterable, Set, ClassVar +from typing import Any, Callable, Generator, Iterable, Set, ClassVar, Literal import warnings from aiohttp.abc import AbstractView @@ -17,6 +17,7 @@ from .injectors import ( MatchInfoGetter, QueryGetter, _parse_func_signature, + CONTEXT, ) @@ -88,6 +89,22 @@ class PydanticView(AbstractView): injectors.append(HeadersGetter(header_args, default_value(header_args))) return injectors + async def on_validation_error( + self, exception: ValidationError, context: CONTEXT + ) -> StreamResponse: + """ + This method is a hook to intercept ValidationError. + + This hook can be redefined to return a custom HTTP response error. + The exception is a pydantic.ValidationError and the context is "body", + "headers", "path" or "query string" + """ + errors = exception.errors() + for error in errors: + error["in"] = context + + return json_response(data=errors, status=400) + def inject_params( handler, parse_func_signature: Callable[[Callable], Iterable[AbstractInjector]] @@ -109,11 +126,7 @@ def inject_params( else: injector.inject(self.request, args, kwargs) except ValidationError as error: - errors = error.errors() - for error in errors: - error["in"] = injector.context - - return json_response(data=errors, status=400) + return await self.on_validation_error(error, injector.context) return await handler(self, *args, **kwargs) diff --git a/tests/test_hook_to_custom_response.py b/tests/test_hook_to_custom_response.py new file mode 100644 index 0000000..069005b --- /dev/null +++ b/tests/test_hook_to_custom_response.py @@ -0,0 +1,56 @@ +from typing import Iterator, List, Optional + +from aiohttp import web +from aiohttp.web_response import json_response +from pydantic import BaseModel + +from aiohttp_pydantic import PydanticView + + +class ArticleModel(BaseModel): + name: str + nb_page: Optional[int] + + +class ArticleModels(BaseModel): + __root__: List[ArticleModel] + + def __iter__(self) -> Iterator[ArticleModel]: + return iter(self.__root__) + + +class ArticleView(PydanticView): + + async def post(self, article: ArticleModel): + return web.json_response(article.dict()) + + async def put(self, articles: ArticleModels): + return web.json_response([article.dict() for article in articles]) + + async def on_validation_error(self, exception, context): + errors = exception.errors() + for error in errors: + error["in"] = context + error["custom"] = "custom" + return json_response(data=errors, status=400) + + +async def test_post_an_article_with_wrong_type_field_should_return_an_error_message( + aiohttp_client, loop +): + app = web.Application() + app.router.add_view("/article", ArticleView) + + client = await aiohttp_client(app) + resp = await client.post("/article", json={"name": "foo", "nb_page": "foo"}) + assert resp.status == 400 + assert resp.content_type == "application/json" + assert await resp.json() == [ + { + "in": "body", + "loc": ["nb_page"], + "msg": "value is not a valid integer", + "custom": "custom", + "type": "type_error.integer", + } + ] From 43d27896366bbb46e1cd22e43f3ec0508d312543 Mon Sep 17 00:00:00 2001 From: Vincent Maillol Date: Mon, 26 Jul 2021 09:04:35 +0200 Subject: [PATCH 17/26] version 1.10.0 --- aiohttp_pydantic/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aiohttp_pydantic/__init__.py b/aiohttp_pydantic/__init__.py index ac08759..630fee7 100644 --- a/aiohttp_pydantic/__init__.py +++ b/aiohttp_pydantic/__init__.py @@ -1,5 +1,5 @@ from .view import PydanticView -__version__ = "1.9.1" +__version__ = "1.10.0" __all__ = ("PydanticView", "__version__") From 9a624437f4495464678fb7f3507e76122fc032f9 Mon Sep 17 00:00:00 2001 From: "Kirill A. Golubev" Date: Mon, 2 Aug 2021 14:46:05 +0300 Subject: [PATCH 18/26] add support of Optional[Union[T]] --- aiohttp_pydantic/oas/view.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aiohttp_pydantic/oas/view.py b/aiohttp_pydantic/oas/view.py index b2fc9e5..e75ac7f 100644 --- a/aiohttp_pydantic/oas/view.py +++ b/aiohttp_pydantic/oas/view.py @@ -26,7 +26,7 @@ def _handle_optional(type_): """ if typing.get_origin(type_) is typing.Union: args = typing.get_args(type_) - if len(args) == 2 and type(None) in args: + if len(args) >= 2 and type(None) in args: return next(iter(set(args) - {type(None)})) return None From c1a63e55b20612eff55c21a010ce550b23872b2d Mon Sep 17 00:00:00 2001 From: Vincent Maillol Date: Wed, 4 Aug 2021 08:09:43 +0200 Subject: [PATCH 19/26] Add unit tests --- aiohttp_pydantic/oas/view.py | 21 +++------------------ tests/test_oas/test_view.py | 20 +++++++++++++++++--- 2 files changed, 20 insertions(+), 21 deletions(-) diff --git a/aiohttp_pydantic/oas/view.py b/aiohttp_pydantic/oas/view.py index e75ac7f..a9594e1 100644 --- a/aiohttp_pydantic/oas/view.py +++ b/aiohttp_pydantic/oas/view.py @@ -16,21 +16,6 @@ from ..view import PydanticView, is_pydantic_view from .typing import is_status_code_type -def _handle_optional(type_): - """ - Returns the type wrapped in Optional or None. - >>> from typing import Optional - >>> _handle_optional(int) - >>> _handle_optional(Optional[str]) - - """ - if typing.get_origin(type_) is typing.Union: - args = typing.get_args(type_) - if len(args) >= 2 and type(None) in args: - return next(iter(set(args) - {type(None)})) - return None - - class _OASResponseBuilder: """ Parse the type annotated as returned by a function and @@ -128,18 +113,18 @@ def _add_http_method_to_oas( i = next(indexes) oas_operation.parameters[i].in_ = args_location oas_operation.parameters[i].name = name - optional_type = _handle_optional(type_) attrs = {"__annotations__": {"__root__": type_}} if name in defaults: attrs["__root__"] = defaults[name] + oas_operation.parameters[i].required = False + else: + oas_operation.parameters[i].required = True oas_operation.parameters[i].schema = type(name, (BaseModel,), attrs).schema( ref_template="#/components/schemas/{model}" ) - oas_operation.parameters[i].required = optional_type is None - return_type = handler.__annotations__.get("return") if return_type is not None: _OASResponseBuilder(oas, oas_operation, status_code_descriptions).build( diff --git a/tests/test_oas/test_view.py b/tests/test_oas/test_view.py index b95c60b..b6de616 100644 --- a/tests/test_oas/test_view.py +++ b/tests/test_oas/test_view.py @@ -1,5 +1,5 @@ from enum import Enum -from typing import List, Optional, Union +from typing import List, Optional, Union, Literal from uuid import UUID import pytest @@ -46,7 +46,7 @@ class PetCollectionView(PydanticView): class PetItemView(PydanticView): - async def get(self, id: int, /) -> Union[r200[Pet], r404]: + async def get(self, id: int, /, size: Union[int, Literal['x', 'l', 's']], day: Union[int, Literal["now"]] = "now") -> Union[r200[Pet], r404]: return web.json_response() async def put(self, id: int, /, pet: Pet): @@ -245,7 +245,21 @@ async def test_pets_id_route_should_have_get_method(generated_oas): "name": "id", "required": True, "schema": {"title": "id", "type": "integer"}, - } + }, + {'in': 'query', + 'name': 'size', + 'required': True, + 'schema': {'anyOf': [{'type': 'integer'}, + {'enum': ['x', 'l', 's'], + 'type': 'string'}], + 'title': 'size'}}, + {'in': 'query', + 'name': 'day', + 'required': False, + 'schema': {'anyOf': [{'type': 'integer'}, + {'enum': ['now'], 'type': 'string'}], + 'default': 'now', + 'title': 'day'}} ], "responses": { "200": { From adcf4ba902bdab5d884736465a7e39abaf3dad31 Mon Sep 17 00:00:00 2001 From: Vincent Maillol Date: Wed, 4 Aug 2021 09:04:05 +0200 Subject: [PATCH 20/26] version 1.10.1 --- aiohttp_pydantic/__init__.py | 2 +- setup.cfg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/aiohttp_pydantic/__init__.py b/aiohttp_pydantic/__init__.py index 630fee7..ae27b34 100644 --- a/aiohttp_pydantic/__init__.py +++ b/aiohttp_pydantic/__init__.py @@ -1,5 +1,5 @@ from .view import PydanticView -__version__ = "1.10.0" +__version__ = "1.10.1" __all__ = ("PydanticView", "__version__") diff --git a/setup.cfg b/setup.cfg index f6377a4..14f9482 100644 --- a/setup.cfg +++ b/setup.cfg @@ -21,7 +21,7 @@ classifiers = Programming Language :: Python :: 3.8 Programming Language :: Python :: 3.9 Topic :: Software Development :: Libraries :: Application Frameworks - Framework :: AsyncIO + Framework :: aiohttp License :: OSI Approved :: MIT License [options] From dbf1eb6ac4480ab57aee3ee1b3c57e1cc23410eb Mon Sep 17 00:00:00 2001 From: Vincent Maillol Date: Wed, 4 Aug 2021 08:58:07 +0200 Subject: [PATCH 21/26] Add pyinvoke task Ensure git tag matches package versions before uploading --- .drone.jsonnet | 22 ++---- .drone.yml | 37 +++------ .gitignore | 2 + requirements/ci.txt | 26 +++++-- requirements/test.txt | 23 ++++-- setup.cfg | 15 +++- tasks.py | 172 ++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 239 insertions(+), 58 deletions(-) create mode 100644 tasks.py diff --git a/.drone.jsonnet b/.drone.jsonnet index 732497b..3cef70b 100644 --- a/.drone.jsonnet +++ b/.drone.jsonnet @@ -17,16 +17,9 @@ local BuildAndTestPipeline(name, image) = { name: "Install package and test", image: image, commands: [ - "echo Install package", - "pip install -U setuptools wheel pip; pip install .", - "echo Test to import module of package", - "python -c \"import importlib, setuptools; [print(importlib.import_module(package).__name__, '[OK]') for package in setuptools.find_packages() if package.startswith('aiohttp_pydantic.') or package == 'aiohttp_pydantic']\"", - "echo Install CI dependencies", - "pip install -r requirements/ci.txt", - "echo Launch unittest", - "pytest --cov-report=xml --cov=aiohttp_pydantic tests/", - "echo Check the README.rst render", - "python -m readme_renderer -o /dev/null README.rst" + "test \"$(md5sum tasks.py)\" = \"18f864b3ac76119938e3317e49b4ffa1 tasks.py\"", + "pip install -U setuptools wheel pip; pip install invoke", + "invoke prepare-upload" ] }, { @@ -56,7 +49,7 @@ local BuildAndTestPipeline(name, image) = { steps: [ { name: "Install twine and deploy", - image: "python3.8", + image: "python:3.8", environment: { pypi_username: { from_secret: 'pypi_username' @@ -66,10 +59,9 @@ local BuildAndTestPipeline(name, image) = { } }, commands: [ - "pip install --force-reinstall twine wheel", - "python setup.py build bdist_wheel", - "set +x", - "twine upload --non-interactive -u \"$pypi_username\" -p \"$pypi_password\" dist/*" + "test \"$(md5sum tasks.py)\" = \"18f864b3ac76119938e3317e49b4ffa1 tasks.py\"", + "pip install -U setuptools wheel pip; pip install invoke", + "invoke upload --pypi-user \"$pypi_username\" --pypi-password \"$pypi_password\"" ] }, ], diff --git a/.drone.yml b/.drone.yml index cc2ef11..c623f97 100644 --- a/.drone.yml +++ b/.drone.yml @@ -11,16 +11,9 @@ steps: - name: Install package and test image: python:3.8 commands: - - echo Install package - - pip install -U setuptools wheel pip; pip install . - - echo Test to import module of package - - python -c "import importlib, setuptools; [print(importlib.import_module(package).__name__, '[OK]') for package in setuptools.find_packages() if package.startswith('aiohttp_pydantic.') or package == 'aiohttp_pydantic']" - - echo Install CI dependencies - - pip install -r requirements/ci.txt - - echo Launch unittest - - pytest --cov-report=xml --cov=aiohttp_pydantic tests/ - - echo Check the README.rst render - - python -m readme_renderer -o /dev/null README.rst + - test "$(md5sum tasks.py)" = "18f864b3ac76119938e3317e49b4ffa1 tasks.py" + - pip install -U setuptools wheel pip; pip install invoke + - invoke prepare-upload - name: coverage image: plugins/codecov @@ -48,16 +41,9 @@ steps: - name: Install package and test image: python:3.9 commands: - - echo Install package - - pip install -U setuptools wheel pip; pip install . - - echo Test to import module of package - - python -c "import importlib, setuptools; [print(importlib.import_module(package).__name__, '[OK]') for package in setuptools.find_packages() if package.startswith('aiohttp_pydantic.') or package == 'aiohttp_pydantic']" - - echo Install CI dependencies - - pip install -r requirements/ci.txt - - echo Launch unittest - - pytest --cov-report=xml --cov=aiohttp_pydantic tests/ - - echo Check the README.rst render - - python -m readme_renderer -o /dev/null README.rst + - test "$(md5sum tasks.py)" = "18f864b3ac76119938e3317e49b4ffa1 tasks.py" + - pip install -U setuptools wheel pip; pip install invoke + - invoke prepare-upload - name: coverage image: plugins/codecov @@ -83,12 +69,11 @@ platform: steps: - name: Install twine and deploy - image: python3.8 + image: python:3.8 commands: - - pip install --force-reinstall twine wheel - - python setup.py build bdist_wheel - - set +x - - twine upload --non-interactive -u "$pypi_username" -p "$pypi_password" dist/* + - test "$(md5sum tasks.py)" = "18f864b3ac76119938e3317e49b4ffa1 tasks.py" + - pip install -U setuptools wheel pip; pip install invoke + - invoke upload --pypi-user "$pypi_username" --pypi-password "$pypi_password" environment: pypi_password: from_secret: pypi_password @@ -105,6 +90,6 @@ depends_on: --- kind: signature -hmac: dfd0429e3b9f364147c56a400cf37466d0cbf0966e613f11b726777553fd9931 +hmac: 9a24ccae6182723af71257495d7843fd40874006c5e867cdebf363f497ddb839 ... diff --git a/.gitignore b/.gitignore index f6640f4..f3f07b7 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,5 @@ aiohttp_pydantic.egg-info/ build/ coverage.xml dist/ +dist_venv/ +venv/ \ No newline at end of file diff --git a/requirements/ci.txt b/requirements/ci.txt index 30b16b6..1bb68d6 100644 --- a/requirements/ci.txt +++ b/requirements/ci.txt @@ -1,28 +1,42 @@ -aiohttp==3.7.3 async-timeout==3.0.1 attrs==21.2.0 -bleach==3.3.0 +bleach==4.0.0 certifi==2021.5.30 -chardet==3.0.4 +cffi==1.14.6 +chardet==4.0.0 +charset-normalizer==2.0.4 codecov==2.1.11 +colorama==0.4.4 coverage==5.5 +cryptography==3.4.7 docutils==0.17.1 -idna==2.10 +idna==3.2 +importlib-metadata==4.6.3 iniconfig==1.1.1 +jeepney==0.7.1 +keyring==23.0.1 multidict==5.1.0 packaging==21.0 +pkginfo==1.7.1 pluggy==0.13.1 py==1.10.0 +pycparser==2.20 Pygments==2.9.0 pyparsing==2.4.7 pytest==6.1.2 pytest-aiohttp==0.3.0 pytest-cov==2.10.1 readme-renderer==29.0 -requests==2.25.1 +requests==2.26.0 +requests-toolbelt==0.9.1 +rfc3986==1.5.0 +SecretStorage==3.3.1 six==1.16.0 toml==0.10.2 +tqdm==4.62.0 +twine==3.4.2 typing-extensions==3.10.0.0 urllib3==1.26.6 webencodings==0.5.1 -yarl==1.6.3 \ No newline at end of file +yarl==1.6.3 +zipp==3.5.0 diff --git a/requirements/test.txt b/requirements/test.txt index cd9319c..5228fdc 100644 --- a/requirements/test.txt +++ b/requirements/test.txt @@ -1,14 +1,23 @@ -attrs-21.2.0 -coverage-5.5 +async-timeout==3.0.1 +attrs==21.2.0 +bleach==4.0.0 +chardet==4.0.0 +coverage==5.5 +docutils==0.17.1 +idna==3.2 iniconfig==1.1.1 +multidict==5.1.0 packaging==21.0 pluggy==0.13.1 py==1.10.0 +Pygments==2.9.0 pyparsing==2.4.7 -pytest==6.2.4 +pytest==6.1.2 pytest-aiohttp==0.3.0 -pytest-cov-2.12.1 -pyyaml==5.3.1 -six==1.15.0 +pytest-cov==2.10.1 +readme-renderer==29.0 +six==1.16.0 toml==0.10.2 -typing-extensions==3.7.4.3 +typing-extensions==3.10.0.0 +webencodings==0.5.1 +yarl==1.6.3 diff --git a/setup.cfg b/setup.cfg index f6377a4..39a51f7 100644 --- a/setup.cfg +++ b/setup.cfg @@ -35,13 +35,20 @@ install_requires = swagger-ui-bundle [options.extras_require] -test = pytest==6.1.2; pytest-aiohttp==0.3.0; pytest-cov==2.10.1 -ci = pytest==6.1.2; pytest-aiohttp==0.3.0; pytest-cov==2.10.1; codecov==2.1.11; readme-renderer==29.0 +test = + pytest==6.1.2 + pytest-aiohttp==0.3.0 + pytest-cov==2.10.1 + readme-renderer==29.0 +ci = + %(test)s + codecov==2.1.11 + twine==3.4.2 [options.packages.find] exclude = - tests - demo + tests* + demo* [options.package_data] aiohttp_pydantic.oas = index.j2 diff --git a/tasks.py b/tasks.py new file mode 100644 index 0000000..96bf572 --- /dev/null +++ b/tasks.py @@ -0,0 +1,172 @@ +""" +To use this module, install invoke and type invoke -l +""" + +from functools import partial +import os +from pathlib import Path +from setuptools.config import read_configuration + +from invoke import task, Exit, Task as Task_, call + + +def activate_venv(c, venv: str): + """ + Activate a virtualenv + """ + virtual_env = Path().absolute() / venv + if original_path := os.environ.get("PATH"): + path = f'{virtual_env / "bin"}:{original_path}' + else: + path = str(virtual_env / "bin") + c.config.run.env["PATH"] = path + c.config.run.env["VIRTUAL_ENV"] = str(virtual_env) + os.environ.pop("PYTHONHOME", "") + + +def title(text, underline_char="#"): + """ + Display text as a title. + """ + template = f"{{:{underline_char}^80}}" + text = template.format(f" {text.strip()} ") + print(f"\033[1m{text}\033[0m") + + +class Task(Task_): + """ + This task add 'skip_if_recent' feature. + + >>> @task(skip_if_recent=['./target', './dependency']) + >>> def my_tash(c): + >>> ... + + target is file created by the task + dependency is file used by the task + + The task is ran only if the dependency is more recent than the target file. + The target or the dependency can be a tuple of files. + """ + + def __init__(self, *args, **kwargs): + self.skip_if_recent = kwargs.pop("skip_if_recent", None) + super().__init__(*args, **kwargs) + + def __call__(self, *args, **kwargs): + title(self.__doc__ or self.name) + + if self.skip_if_recent: + targets, dependencies = self.skip_if_recent + if isinstance(targets, str): + targets = (targets,) + if isinstance(dependencies, str): + dependencies = (dependencies,) + + target_mtime = min( + ((Path(file).exists() and Path(file).lstat().st_mtime) or 0) + for file in targets + ) + dependency_mtime = max(Path(file).lstat().st_mtime for file in dependencies) + + if dependency_mtime < target_mtime: + print(f"{self.name}, nothing to do") + return None + + return super().__call__(*args, **kwargs) + + +task = partial(task, klass=Task) + + +@task() +def venv(c): + """ + Create a virtual environment for dev + """ + c.run("python -m venv --clear venv") + c.run("venv/bin/pip install -U setuptools wheel pip") + c.run("venv/bin/pip install -e .") + c.run("venv/bin/pip install -r requirements/test.txt") + + +@task() +def check_readme(c): + """ + Check the README.rst render + """ + c.run("python -m readme_renderer -o /dev/null README.rst") + + +@task() +def test(c, isolate=False): + """ + Launch tests + """ + opt = "I" if isolate else "" + c.run(f"python -{opt}m pytest --cov-report=xml --cov=aiohttp_pydantic tests/") + + +@task() +def tag_eq_version(c): + """ + Ensure that the last git tag matches the package version + """ + git_tag = c.run("git describe --tags HEAD", hide=True).stdout.strip() + package_version = read_configuration("./setup.cfg")["metadata"]["version"] + if git_tag != f"v{package_version}": + raise Exit( + f"ERROR: The git tag {git_tag!r} does not matches" + f" the package version {package_version!r}" + ) + + +@task() +def prepare_ci_env(c): + """ + Prepare CI environment + """ + title("Creating virtual env", "=") + c.run("python -m venv --clear dist_venv") + activate_venv(c, "dist_venv") + + c.run("dist_venv/bin/python -m pip install -U setuptools wheel pip") + + title("Building wheel", "=") + c.run("dist_venv/bin/python setup.py build bdist_wheel") + + title("Installing wheel", "=") + package_version = read_configuration("./setup.cfg")["metadata"]["version"] + dist = next(Path("dist").glob(f"aiohttp_pydantic-{package_version}-*.whl")) + c.run(f"dist_venv/bin/python -m pip install {dist}") + + # We verify that aiohttp-pydantic module is importable before installing CI tools. + package_names = read_configuration("./setup.cfg")["options"]["packages"] + for package_name in package_names: + c.run(f"dist_venv/bin/python -I -c 'import {package_name}'") + + title("Installing CI tools", "=") + c.run("dist_venv/bin/python -m pip install -r requirements/ci.txt") + + +@task(prepare_ci_env, check_readme, call(test, isolate=True), klass=Task_) +def prepare_upload(c): + """ + Launch all tests and verifications + """ + + +@task(tag_eq_version, prepare_upload) +def upload(c, pypi_user=None, pypi_password=None): + """ + Upload on pypi + """ + package_version = read_configuration("./setup.cfg")["metadata"]["version"] + dist = next(Path("dist").glob(f"aiohttp_pydantic-{package_version}-*.whl")) + if pypi_user is not None and pypi_password is not None: + c.run( + f"dist_venv/bin/twine upload --non-interactive" + f" -u {pypi_user} -p {pypi_password} {dist}", + hide=True, + ) + else: + c.run(f"dist_venv/bin/twine upload --repository aiohttp-pydantic {dist}") From fa7e8d914bad3a76932244c59f0247cf9a09cd11 Mon Sep 17 00:00:00 2001 From: Vincent Maillol Date: Sun, 22 Aug 2021 14:32:19 +0200 Subject: [PATCH 22/26] We can add custom tags to generated OPS --- README.rst | 5 ++ aiohttp_pydantic/oas/docstring_parser.py | 38 ++++++++--- aiohttp_pydantic/oas/struct.py | 15 ++++- aiohttp_pydantic/oas/view.py | 3 +- aiohttp_pydantic/view.py | 2 +- tests/test_hook_to_custom_response.py | 1 - tests/test_oas/test_docstring_parser.py | 85 +++++++++++++++++++++--- tests/test_oas/test_struct/test_paths.py | 18 +++++ tests/test_oas/test_view.py | 46 ++++++++----- 9 files changed, 173 insertions(+), 40 deletions(-) diff --git a/README.rst b/README.rst index 6c95003..69b005a 100644 --- a/README.rst +++ b/README.rst @@ -269,6 +269,8 @@ Open Api Specification. async def get(self) -> r200[List[Pet]]: """ Find all pets + + Tags: pet """ pets = self.request.app["model"].list_pets() return web.json_response([pet.dict() for pet in pets]) @@ -277,6 +279,7 @@ Open Api Specification. """ Add a new pet to the store + Tags: pet Status Codes: 201: The pet is created """ @@ -289,6 +292,7 @@ Open Api Specification. """ Find a pet by ID + Tags: pet Status Codes: 200: Successful operation 404: Pet not found @@ -300,6 +304,7 @@ Open Api Specification. """ Update an existing pet + Tags: pet Status Codes: 200: successful operation """ diff --git a/aiohttp_pydantic/oas/docstring_parser.py b/aiohttp_pydantic/oas/docstring_parser.py index ed2aea7..eda47ef 100644 --- a/aiohttp_pydantic/oas/docstring_parser.py +++ b/aiohttp_pydantic/oas/docstring_parser.py @@ -4,7 +4,7 @@ Utility to extract extra OAS description from docstring. import re import textwrap -from typing import Dict +from typing import Dict, List class LinesIterator: @@ -47,11 +47,10 @@ def _i_extract_block(lines: LinesIterator): except StopIteration: return - # Get the size of the indentation. - if (match := re.search("^ +", line)) is None: - return # No block to extract. - indent = match.group() - yield line[len(indent) :] + indent = re.fullmatch("( *).*", line).groups()[0] + indentation = len(indent) + start_of_other_block = re.compile(f" {{0,{indentation}}}[^ ].*") + yield line[indentation:] # Yield lines until the indentation is the same or is greater than # the first block line. @@ -59,8 +58,8 @@ def _i_extract_block(lines: LinesIterator): line = next(lines) except StopIteration: return - while (is_empty := line.strip() == "") or line.startswith(indent): - yield "" if is_empty else line[len(indent) :] + while not start_of_other_block.fullmatch(line): + yield line[indentation:] try: line = next(lines) except StopIteration: @@ -87,10 +86,13 @@ def status_code(docstring: str) -> Dict[int, str]: iterator = LinesIterator(docstring) for line in iterator: if re.fullmatch("status\\s+codes?\\s*:", line, re.IGNORECASE): + iterator.rewind() blocks = [] lines = [] - for line_of_block in _i_extract_block(iterator): - if re.search("^\\d{3}\\s*:", line_of_block): + i_block = _i_extract_block(iterator) + next(i_block) + for line_of_block in i_block: + if re.search("^\\s*\\d{3}\\s*:", line_of_block): if lines: blocks.append("\n".join(lines)) lines = [] @@ -105,6 +107,19 @@ def status_code(docstring: str) -> Dict[int, str]: return {} +def tags(docstring: str) -> List[str]: + """ + Extract the "Tags:" block of the docstring. + """ + iterator = LinesIterator(docstring) + for line in iterator: + if re.fullmatch("tags\\s*:.*", line, re.IGNORECASE): + iterator.rewind() + lines = " ".join(_i_extract_block(iterator)) + return [" ".join(e.split()) for e in re.split("[,;]", lines.split(":")[1])] + return [] + + def operation(docstring: str) -> str: """ Extract all docstring except the "Status Code:" block. @@ -112,7 +127,8 @@ def operation(docstring: str) -> str: lines = LinesIterator(docstring) ret = [] for line in lines: - if re.fullmatch("status\\s+codes?\\s*:", line, re.IGNORECASE): + if re.fullmatch("status\\s+codes?\\s*:|tags\\s*:.*", line, re.IGNORECASE): + lines.rewind() for _ in _i_extract_block(lines): pass else: diff --git a/aiohttp_pydantic/oas/struct.py b/aiohttp_pydantic/oas/struct.py index cf01e9c..e03a62d 100644 --- a/aiohttp_pydantic/oas/struct.py +++ b/aiohttp_pydantic/oas/struct.py @@ -2,7 +2,7 @@ Utility to write Open Api Specifications using the Python language. """ -from typing import Union +from typing import Union, List class Info: @@ -157,7 +157,7 @@ class Responses: self._spec = spec.setdefault("responses", {}) def __getitem__(self, status_code: Union[int, str]) -> Response: - if not (100 <= int(status_code) < 600): + if not 100 <= int(status_code) < 600: raise ValueError("status_code must be between 100 and 599") spec = self._spec.setdefault(str(status_code), {}) @@ -196,6 +196,17 @@ class OperationObject: def responses(self) -> Responses: return Responses(self._spec) + @property + def tags(self) -> List[str]: + return self._spec.get("tags", [])[:] + + @tags.setter + def tags(self, tags: List[str]): + if tags: + self._spec["tags"] = tags[:] + else: + self._spec.pop("tags", None) + class PathItem: def __init__(self, spec: dict): diff --git a/aiohttp_pydantic/oas/view.py b/aiohttp_pydantic/oas/view.py index a9594e1..778513f 100644 --- a/aiohttp_pydantic/oas/view.py +++ b/aiohttp_pydantic/oas/view.py @@ -1,7 +1,7 @@ import typing from inspect import getdoc from itertools import count -from typing import List, Type, Optional, Dict +from typing import List, Type, Optional from aiohttp.web import Response, json_response from aiohttp.web_app import Application @@ -86,6 +86,7 @@ def _add_http_method_to_oas( description = getdoc(handler) if description: oas_operation.description = docstring_parser.operation(description) + oas_operation.tags = docstring_parser.tags(description) status_code_descriptions = docstring_parser.status_code(description) else: status_code_descriptions = {} diff --git a/aiohttp_pydantic/view.py b/aiohttp_pydantic/view.py index 355def5..00093bc 100644 --- a/aiohttp_pydantic/view.py +++ b/aiohttp_pydantic/view.py @@ -1,6 +1,6 @@ from functools import update_wrapper from inspect import iscoroutinefunction -from typing import Any, Callable, Generator, Iterable, Set, ClassVar, Literal +from typing import Any, Callable, Generator, Iterable, Set, ClassVar import warnings from aiohttp.abc import AbstractView diff --git a/tests/test_hook_to_custom_response.py b/tests/test_hook_to_custom_response.py index 069005b..c9aa6b5 100644 --- a/tests/test_hook_to_custom_response.py +++ b/tests/test_hook_to_custom_response.py @@ -20,7 +20,6 @@ class ArticleModels(BaseModel): class ArticleView(PydanticView): - async def post(self, article: ArticleModel): return web.json_response(article.dict()) diff --git a/tests/test_oas/test_docstring_parser.py b/tests/test_oas/test_docstring_parser.py index 4794973..aaf964f 100644 --- a/tests/test_oas/test_docstring_parser.py +++ b/tests/test_oas/test_docstring_parser.py @@ -1,5 +1,8 @@ +from textwrap import dedent + from aiohttp_pydantic.oas.docstring_parser import ( status_code, + tags, operation, _i_extract_block, LinesIterator, @@ -13,6 +16,13 @@ def web_handler(): bla bla bla + Tags: tag1, tag2 + , tag3, + + t a + g + 4 + Status Codes: 200: line 1 @@ -36,6 +46,19 @@ def web_handler(): """ +def web_handler_2(): + """ + bla bla bla + + + Tags: tag1 + Status Codes: + 200: line 1 + + bla bla + """ + + def test_lines_iterator(): lines_iterator = LinesIterator("AAAA\nBBBB") with pytest.raises(StopIteration): @@ -61,28 +84,72 @@ def test_status_code(): assert status_code(getdoc(web_handler)) == expected +def test_tags(): + expected = ["tag1", "tag2", "tag3", "t a g 4"] + assert tags(getdoc(web_handler)) == expected + + def test_operation(): expected = "bla bla bla\n\n\nbla bla" assert operation(getdoc(web_handler)) == expected + assert operation(getdoc(web_handler_2)) == expected def test_i_extract_block(): - lines = LinesIterator(" aaaa\n\n bbbb\n\n cccc\n dddd") + + blocks = dedent( + """ + aaaa: + + bbbb + + cccc + dddd + """ + ) + + lines = LinesIterator(blocks) text = "\n".join(_i_extract_block(lines)) - assert text == """aaaa\n\n bbbb\n\ncccc""" + assert text == """aaaa:\n\n bbbb\n\n cccc""" + + blocks = dedent( + """ + aaaa: + + bbbb + + cccc + + dddd + """ + ) + + lines = LinesIterator(blocks) + text = "\n".join(_i_extract_block(lines)) + assert text == """aaaa:\n\n bbbb\n\n cccc\n""" + + blocks = dedent( + """ + aaaa: + + bbbb + + cccc + """ + ) + + lines = LinesIterator(blocks) + text = "\n".join(_i_extract_block(lines)) + assert text == """aaaa:\n\n bbbb\n\n cccc""" lines = LinesIterator("") text = "\n".join(_i_extract_block(lines)) assert text == "" - lines = LinesIterator("aaaa\n bbbb") # the indented block is cut by a new block + lines = LinesIterator("\n") text = "\n".join(_i_extract_block(lines)) assert text == "" - lines = LinesIterator(" \n ") + lines = LinesIterator("aaaa:") text = "\n".join(_i_extract_block(lines)) - assert text == "" - - lines = LinesIterator(" aaaa\n bbbb") - text = "\n".join(_i_extract_block(lines)) - assert text == "aaaa\nbbbb" + assert text == "aaaa:" diff --git a/tests/test_oas/test_struct/test_paths.py b/tests/test_oas/test_struct/test_paths.py index f321a62..2c97182 100644 --- a/tests/test_oas/test_struct/test_paths.py +++ b/tests/test_oas/test_struct/test_paths.py @@ -119,6 +119,24 @@ def test_paths_operation_requestBody(): } +def test_paths_operation_tags(): + oas = OpenApiSpec3() + operation = oas.paths["/users/{petId}"].get + assert operation.tags == [] + operation.tags = ['pets'] + + assert oas.spec['paths']['/users/{petId}'] == { + 'get': { + 'tags': ['pets'] + } + } + + operation.tags = [] + assert oas.spec['paths']['/users/{petId}'] == { + 'get': {} + } + + def test_paths_operation_responses(): oas = OpenApiSpec3() response = oas.paths["/users/{petId}"].get.responses[200] diff --git a/tests/test_oas/test_view.py b/tests/test_oas/test_view.py index b6de616..3eb4c34 100644 --- a/tests/test_oas/test_view.py +++ b/tests/test_oas/test_view.py @@ -35,6 +35,7 @@ class PetCollectionView(PydanticView): """ Get a list of pets + Tags: pet Status Codes: 200: Successful operation """ @@ -46,7 +47,13 @@ class PetCollectionView(PydanticView): class PetItemView(PydanticView): - async def get(self, id: int, /, size: Union[int, Literal['x', 'l', 's']], day: Union[int, Literal["now"]] = "now") -> Union[r200[Pet], r404]: + async def get( + self, + id: int, + /, + size: Union[int, Literal["x", "l", "s"]], + day: Union[int, Literal["now"]] = "now", + ) -> Union[r200[Pet], r404]: return web.json_response() async def put(self, id: int, /, pet: Pet): @@ -120,6 +127,7 @@ async def test_generated_oas_should_have_pets_paths(generated_oas): async def test_pets_route_should_have_get_method(generated_oas): assert generated_oas["paths"]["/pets"]["get"] == { "description": "Get a list of pets", + "tags": ["pet"], "parameters": [ { "in": "query", @@ -246,20 +254,28 @@ async def test_pets_id_route_should_have_get_method(generated_oas): "required": True, "schema": {"title": "id", "type": "integer"}, }, - {'in': 'query', - 'name': 'size', - 'required': True, - 'schema': {'anyOf': [{'type': 'integer'}, - {'enum': ['x', 'l', 's'], - 'type': 'string'}], - 'title': 'size'}}, - {'in': 'query', - 'name': 'day', - 'required': False, - 'schema': {'anyOf': [{'type': 'integer'}, - {'enum': ['now'], 'type': 'string'}], - 'default': 'now', - 'title': 'day'}} + { + "in": "query", + "name": "size", + "required": True, + "schema": { + "anyOf": [ + {"type": "integer"}, + {"enum": ["x", "l", "s"], "type": "string"}, + ], + "title": "size", + }, + }, + { + "in": "query", + "name": "day", + "required": False, + "schema": { + "anyOf": [{"type": "integer"}, {"enum": ["now"], "type": "string"}], + "default": "now", + "title": "day", + }, + }, ], "responses": { "200": { From 4a49d3b53da231c417839f210371186263a5f4db Mon Sep 17 00:00:00 2001 From: Vincent Maillol Date: Sun, 22 Aug 2021 15:06:11 +0200 Subject: [PATCH 23/26] update version --- aiohttp_pydantic/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aiohttp_pydantic/__init__.py b/aiohttp_pydantic/__init__.py index ae27b34..e938416 100644 --- a/aiohttp_pydantic/__init__.py +++ b/aiohttp_pydantic/__init__.py @@ -1,5 +1,5 @@ from .view import PydanticView -__version__ = "1.10.1" +__version__ = "1.11.0" __all__ = ("PydanticView", "__version__") From 799080bbd0325a151d6343354f73af2633b345a3 Mon Sep 17 00:00:00 2001 From: Vincent Maillol Date: Sun, 26 Sep 2021 19:08:39 +0200 Subject: [PATCH 24/26] Add group parameter feature --- README.rst | 63 +++++++++++ aiohttp_pydantic/__init__.py | 2 +- aiohttp_pydantic/injectors.py | 132 +++++++++++++++++++++-- aiohttp_pydantic/oas/view.py | 2 +- aiohttp_pydantic/utils.py | 10 +- aiohttp_pydantic/view.py | 12 +++ tests/test_group.py | 74 +++++++++++++ tests/test_oas/test_struct/test_paths.py | 12 +-- tests/test_oas/test_view.py | 60 ++++++++--- tests/test_validation_header.py | 44 ++++++++ tests/test_validation_query_string.py | 95 ++++++++++++++++ 11 files changed, 472 insertions(+), 34 deletions(-) create mode 100644 tests/test_group.py diff --git a/README.rst b/README.rst index 69b005a..3c5eb03 100644 --- a/README.rst +++ b/README.rst @@ -316,6 +316,69 @@ Open Api Specification. return web.Response(status=204) +Group parameters +---------------- + +If your method has lot of parameters you can group them together inside one or several Groups. + + +.. code-block:: python3 + + class Pagination(Group): + page_num: int = 1 + page_size: int = 15 + + + class ArticleView(PydanticView): + + async def get(self, page: Pagination): + articles = Article.get(page.page_num, page.page_size) + ... + + +The parameters page_num and page_size are expected in the query string, and +set inside a Pagination object passed as page parameter. + +The code above is equivalent to: + + +.. code-block:: python3 + + class ArticleView(PydanticView): + + async def get(self, page_num: int = 1, page_size: int = 15): + articles = Article.get(page_num, page_size) + ... + + +You can add methods or properties to your Group. + + +.. code-block:: python3 + + class Pagination(Group): + page_num: int = 1 + page_size: int = 15 + + @property + def num(self): + return self.page_num + + @property + def size(self): + return self.page_size + + def slice(self): + return slice(self.num, self.size) + + + class ArticleView(PydanticView): + + async def get(self, page: Pagination): + articles = Article.get(page.num, page.size) + ... + + Custom Validation error ----------------------- diff --git a/aiohttp_pydantic/__init__.py b/aiohttp_pydantic/__init__.py index e938416..81d1052 100644 --- a/aiohttp_pydantic/__init__.py +++ b/aiohttp_pydantic/__init__.py @@ -1,5 +1,5 @@ from .view import PydanticView -__version__ = "1.11.0" +__version__ = "1.12.0" __all__ = ("PydanticView", "__version__") diff --git a/aiohttp_pydantic/injectors.py b/aiohttp_pydantic/injectors.py index 8147c4c..848474c 100644 --- a/aiohttp_pydantic/injectors.py +++ b/aiohttp_pydantic/injectors.py @@ -1,16 +1,16 @@ import abc import typing -from inspect import signature +from inspect import signature, getmro from json.decoder import JSONDecodeError -from typing import Callable, Tuple, Literal +from types import SimpleNamespace +from typing import Callable, Tuple, Literal, Type from aiohttp.web_exceptions import HTTPBadRequest from aiohttp.web_request import BaseRequest from multidict import MultiDict from pydantic import BaseModel -from .utils import is_pydantic_base_model - +from .utils import is_pydantic_base_model, robuste_issubclass CONTEXT = Literal["body", "headers", "path", "query string"] @@ -20,6 +20,8 @@ class AbstractInjector(metaclass=abc.ABCMeta): An injector parse HTTP request and inject params to the view. """ + model: Type[BaseModel] + @property @abc.abstractmethod def context(self) -> CONTEXT: @@ -96,8 +98,17 @@ class QueryGetter(AbstractInjector): context = "query string" def __init__(self, args_spec: dict, default_values: dict): + args_spec = args_spec.copy() + + self._groups = {} + for group_name, group in args_spec.items(): + if robuste_issubclass(group, Group): + self._groups[group_name] = (group, _get_group_signature(group)[0]) + + _unpack_group_in_signature(args_spec, default_values) attrs = {"__annotations__": args_spec} attrs.update(default_values) + self.model = type("QueryModel", (BaseModel,), attrs) self.args_spec = args_spec self._is_multiple = frozenset( @@ -105,7 +116,14 @@ class QueryGetter(AbstractInjector): ) def inject(self, request: BaseRequest, args_view: list, kwargs_view: dict): - kwargs_view.update(self.model(**self._query_to_dict(request.query)).dict()) + data = self._query_to_dict(request.query) + cleaned = self.model(**data).dict() + for group_name, (group_cls, group_attrs) in self._groups.items(): + group = group_cls() + for attr_name in group_attrs: + setattr(group, attr_name, cleaned.pop(attr_name)) + cleaned[group_name] = group + kwargs_view.update(**cleaned) def _query_to_dict(self, query: MultiDict): """ @@ -130,18 +148,74 @@ class HeadersGetter(AbstractInjector): context = "headers" def __init__(self, args_spec: dict, default_values: dict): + args_spec = args_spec.copy() + + self._groups = {} + for group_name, group in args_spec.items(): + if robuste_issubclass(group, Group): + self._groups[group_name] = (group, _get_group_signature(group)[0]) + + _unpack_group_in_signature(args_spec, default_values) + attrs = {"__annotations__": args_spec} attrs.update(default_values) self.model = type("HeaderModel", (BaseModel,), attrs) def inject(self, request: BaseRequest, args_view: list, kwargs_view: dict): header = {k.lower().replace("-", "_"): v for k, v in request.headers.items()} - kwargs_view.update(self.model(**header).dict()) + cleaned = self.model(**header).dict() + for group_name, (group_cls, group_attrs) in self._groups.items(): + group = group_cls() + for attr_name in group_attrs: + setattr(group, attr_name, cleaned.pop(attr_name)) + cleaned[group_name] = group + kwargs_view.update(cleaned) -def _parse_func_signature(func: Callable) -> Tuple[dict, dict, dict, dict, dict]: +class Group(SimpleNamespace): """ - Analyse function signature and returns 4-tuple: + Class to group header or query string parameters. + + The parameter from query string or header will be set in the group + and the group will be passed as function parameter. + + Example: + + class Pagination(Group): + current_page: int = 1 + page_size: int = 15 + + class PetView(PydanticView): + def get(self, page: Pagination): + ... + """ + + +def _get_group_signature(cls) -> Tuple[dict, dict]: + """ + Analyse Group subclass annotations and return them with default values. + """ + + sig = {} + defaults = {} + mro = getmro(cls) + for base in reversed(mro[: mro.index(Group)]): + attrs = vars(base) + for attr_name, type_ in base.__annotations__.items(): + sig[attr_name] = type_ + if (default := attrs.get(attr_name)) is None: + defaults.pop(attr_name, None) + else: + defaults[attr_name] = default + + return sig, defaults + + +def _parse_func_signature( + func: Callable, unpack_group: bool = False +) -> Tuple[dict, dict, dict, dict, dict]: + """ + Analyse function signature and returns 5-tuple: 0 - arguments will be set from the url path 1 - argument will be set from the request body. 2 - argument will be set from the query string. @@ -178,4 +252,46 @@ def _parse_func_signature(func: Callable) -> Tuple[dict, dict, dict, dict, dict] else: raise RuntimeError(f"You cannot use {param_spec.VAR_POSITIONAL} parameters") + if unpack_group: + try: + _unpack_group_in_signature(qs_args, defaults) + _unpack_group_in_signature(header_args, defaults) + except DuplicateNames as error: + raise TypeError( + f"Parameters conflict in function {func}," + f" the group {error.group} has an attribute named {error.attr_name}" + ) from None + return path_args, body_args, qs_args, header_args, defaults + + +class DuplicateNames(Exception): + """ + Raised when a same parameter name is used in group and function signature. + """ + + group: Type[Group] + attr_name: str + + def __init__(self, group: Type[Group], attr_name: str): + self.group = group + self.attr_name = attr_name + super().__init__( + f"Conflict with {group}.{attr_name} and function parameter name" + ) + + +def _unpack_group_in_signature(args: dict, defaults: dict) -> None: + """ + Unpack in place each Group found in args. + """ + for group_name, group in args.copy().items(): + if robuste_issubclass(group, Group): + group_sig, group_default = _get_group_signature(group) + for attr_name in group_sig: + if attr_name in args and attr_name != group_name: + raise DuplicateNames(group, attr_name) + + del args[group_name] + args.update(group_sig) + defaults.update(group_default) diff --git a/aiohttp_pydantic/oas/view.py b/aiohttp_pydantic/oas/view.py index 778513f..2d10609 100644 --- a/aiohttp_pydantic/oas/view.py +++ b/aiohttp_pydantic/oas/view.py @@ -81,7 +81,7 @@ def _add_http_method_to_oas( oas_operation: OperationObject = getattr(oas_path, http_method) handler = getattr(view, http_method) path_args, body_args, qs_args, header_args, defaults = _parse_func_signature( - handler + handler, unpack_group=True ) description = getdoc(handler) if description: diff --git a/aiohttp_pydantic/utils.py b/aiohttp_pydantic/utils.py index efee749..92644a1 100644 --- a/aiohttp_pydantic/utils.py +++ b/aiohttp_pydantic/utils.py @@ -5,7 +5,15 @@ def is_pydantic_base_model(obj): """ Return true is obj is a pydantic.BaseModel subclass. """ + return robuste_issubclass(obj, BaseModel) + + +def robuste_issubclass(cls1, cls2): + """ + function likes issubclass but returns False instead of raise type error + if first parameter is not a class. + """ try: - return issubclass(obj, BaseModel) + return issubclass(cls1, cls2) except TypeError: return False diff --git a/aiohttp_pydantic/view.py b/aiohttp_pydantic/view.py index 00093bc..3b7cd65 100644 --- a/aiohttp_pydantic/view.py +++ b/aiohttp_pydantic/view.py @@ -18,6 +18,7 @@ from .injectors import ( QueryGetter, _parse_func_signature, CONTEXT, + Group, ) @@ -142,3 +143,14 @@ def is_pydantic_view(obj) -> bool: return issubclass(obj, PydanticView) except TypeError: return False + + +__all__ = ( + "AbstractInjector", + "BodyGetter", + "HeadersGetter", + "MatchInfoGetter", + "QueryGetter", + "CONTEXT", + "Group", +) diff --git a/tests/test_group.py b/tests/test_group.py new file mode 100644 index 0000000..24a903f --- /dev/null +++ b/tests/test_group.py @@ -0,0 +1,74 @@ +import pytest + +from aiohttp_pydantic.injectors import ( + Group, + _get_group_signature, + _unpack_group_in_signature, + DuplicateNames, +) + + +def test_get_group_signature_with_a2b2(): + class A(Group): + a: int = 1 + + class B(Group): + b: str = "b" + + class B2(B): + b: str = "b2" # Overwrite default value + + class A2(A): + a: int # Remove default value + + class A2B2(A2, B2): + ab2: float + + assert ({"ab2": float, "a": int, "b": str}, {"b": "b2"}) == _get_group_signature( + A2B2 + ) + + +def test_unpack_group_in_signature(): + class PaginationGroup(Group): + page: int + page_size: int = 20 + + args = {"pagination": PaginationGroup, "name": str, "age": int} + + default = {"age": 18} + + _unpack_group_in_signature(args, default) + + assert args == {"page": int, "page_size": int, "name": str, "age": int} + + assert default == {"age": 18, "page_size": 20} + + +def test_unpack_group_in_signature_with_duplicate_error(): + class PaginationGroup(Group): + page: int + page_size: int = 20 + + args = {"pagination": PaginationGroup, "page": int, "age": int} + + with pytest.raises(DuplicateNames) as e_info: + _unpack_group_in_signature(args, {}) + + assert e_info.value.group is PaginationGroup + assert e_info.value.attr_name == "page" + + +def test_unpack_group_in_signature_with_parameters_overwrite(): + class PaginationGroup(Group): + page: int = 0 + page_size: int = 20 + + args = {"page": PaginationGroup, "age": int} + + default = {} + _unpack_group_in_signature(args, default) + + assert args == {"page": int, "page_size": int, "age": int} + + assert default == {"page": 0, "page_size": 20} diff --git a/tests/test_oas/test_struct/test_paths.py b/tests/test_oas/test_struct/test_paths.py index 2c97182..78453cd 100644 --- a/tests/test_oas/test_struct/test_paths.py +++ b/tests/test_oas/test_struct/test_paths.py @@ -123,18 +123,12 @@ def test_paths_operation_tags(): oas = OpenApiSpec3() operation = oas.paths["/users/{petId}"].get assert operation.tags == [] - operation.tags = ['pets'] + operation.tags = ["pets"] - assert oas.spec['paths']['/users/{petId}'] == { - 'get': { - 'tags': ['pets'] - } - } + assert oas.spec["paths"]["/users/{petId}"] == {"get": {"tags": ["pets"]}} operation.tags = [] - assert oas.spec['paths']['/users/{petId}'] == { - 'get': {} - } + assert oas.spec["paths"]["/users/{petId}"] == {"get": {}} def test_paths_operation_responses(): diff --git a/tests/test_oas/test_view.py b/tests/test_oas/test_view.py index 3eb4c34..af9c338 100644 --- a/tests/test_oas/test_view.py +++ b/tests/test_oas/test_view.py @@ -7,6 +7,7 @@ from aiohttp import web from pydantic.main import BaseModel from aiohttp_pydantic import PydanticView, oas +from aiohttp_pydantic.injectors import Group from aiohttp_pydantic.oas.typing import r200, r201, r204, r404 from aiohttp_pydantic.oas.view import generate_oas @@ -76,6 +77,24 @@ class ViewResponseReturnASimpleType(PydanticView): return web.json_response() +async def ensure_content_durability(client): + """ + Reload the page 2 times to ensure that content is always the same + note: pydantic can return a cached dict, if a view updates the dict the + output will be incoherent + """ + response_1 = await client.get("/oas/spec") + assert response_1.status == 200 + assert response_1.content_type == "application/json" + content_1 = await response_1.json() + + response_2 = await client.get("/oas/spec") + content_2 = await response_2.json() + assert content_1 == content_2 + + return content_2 + + @pytest.fixture async def generated_oas(aiohttp_client, loop) -> web.Application: app = web.Application() @@ -84,20 +103,7 @@ async def generated_oas(aiohttp_client, loop) -> web.Application: app.router.add_view("/simple-type", ViewResponseReturnASimpleType) oas.setup(app) - client = await aiohttp_client(app) - response_1 = await client.get("/oas/spec") - assert response_1.content_type == "application/json" - assert response_1.status == 200 - content_1 = await response_1.json() - - # Reload the page to ensure that content is always the same - # note: pydantic can return a cached dict, if a view updates - # the dict the output will be incoherent - response_2 = await client.get("/oas/spec") - content_2 = await response_2.json() - assert content_1 == content_2 - - return content_2 + return await ensure_content_durability(await aiohttp_client(app)) async def test_generated_oas_should_have_components_schemas(generated_oas): @@ -377,3 +383,29 @@ async def test_generated_view_info_as_title(): "info": {"title": "test title", "version": "1.0.0"}, "openapi": "3.0.0", } + + +async def test_use_parameters_group_should_not_impact_the_oas(aiohttp_client): + class PetCollectionView1(PydanticView): + async def get(self, page: int = 1, page_size: int = 20) -> r200[List[Pet]]: + return web.json_response() + + class Pagination(Group): + page: int = 1 + page_size: int = 20 + + class PetCollectionView2(PydanticView): + async def get(self, pagination: Pagination) -> r200[List[Pet]]: + return web.json_response() + + app1 = web.Application() + app1.router.add_view("/pets", PetCollectionView1) + oas.setup(app1) + + app2 = web.Application() + app2.router.add_view("/pets", PetCollectionView2) + oas.setup(app2) + + assert await ensure_content_durability( + await aiohttp_client(app1) + ) == await ensure_content_durability(await aiohttp_client(app2)) diff --git a/tests/test_validation_header.py b/tests/test_validation_header.py index e18a863..134c6ca 100644 --- a/tests/test_validation_header.py +++ b/tests/test_validation_header.py @@ -5,6 +5,7 @@ from enum import Enum from aiohttp import web from aiohttp_pydantic import PydanticView +from aiohttp_pydantic.injectors import Group class JSONEncoder(json.JSONEncoder): @@ -32,6 +33,31 @@ class ViewWithEnumType(PydanticView): return web.json_response({"format": format}, dumps=JSONEncoder().encode) +class Signature(Group): + signature_expired: datetime + signature_scope: str = "read" + + @property + def expired(self) -> datetime: + return self.signature_expired + + @property + def scope(self) -> str: + return self.signature_scope + + +class ArticleViewWithSignatureGroup(PydanticView): + async def get( + self, + *, + signature: Signature, + ): + return web.json_response( + {"expired": signature.expired, "scope": signature.scope}, + dumps=JSONEncoder().encode, + ) + + async def test_get_article_without_required_header_should_return_an_error_message( aiohttp_client, loop ): @@ -134,3 +160,21 @@ async def test_correct_value_to_header_defined_with_str_enum(aiohttp_client, loo assert await resp.json() == {"format": "UMT"} assert resp.status == 200 assert resp.content_type == "application/json" + + +async def test_with_signature_group(aiohttp_client, loop): + app = web.Application() + app.router.add_view("/article", ArticleViewWithSignatureGroup) + + client = await aiohttp_client(app) + resp = await client.get( + "/article", + headers={ + "signature_expired": "2020-10-04T18:01:00", + "signature.scope": "write", + }, + ) + + assert await resp.json() == {"expired": "2020-10-04T18:01:00", "scope": "read"} + assert resp.status == 200 + assert resp.content_type == "application/json" diff --git a/tests/test_validation_query_string.py b/tests/test_validation_query_string.py index 4b8913f..344cea0 100644 --- a/tests/test_validation_query_string.py +++ b/tests/test_validation_query_string.py @@ -3,6 +3,7 @@ from pydantic import Field from aiohttp import web from aiohttp_pydantic import PydanticView +from aiohttp_pydantic.injectors import Group class ArticleView(PydanticView): @@ -23,6 +24,34 @@ class ArticleView(PydanticView): ) +class Pagination(Group): + page_num: int + page_size: int = 20 + + @property + def num(self) -> int: + return self.page_num + + @property + def size(self) -> int: + return self.page_size + + +class ArticleViewWithPaginationGroup(PydanticView): + async def get( + self, + with_comments: bool, + page: Pagination, + ): + return web.json_response( + { + "with_comments": with_comments, + "page_num": page.num, + "page_size": page.size, + } + ) + + async def test_get_article_without_required_qs_should_return_an_error_message( aiohttp_client, loop ): @@ -158,3 +187,69 @@ async def test_get_article_with_one_value_of_tags_must_be_a_list(aiohttp_client, } assert resp.status == 200 assert resp.content_type == "application/json" + + +async def test_get_article_without_required_field_page(aiohttp_client, loop): + app = web.Application() + app.router.add_view("/article", ArticleViewWithPaginationGroup) + + client = await aiohttp_client(app) + + resp = await client.get("/article", params={"with_comments": 1}) + assert await resp.json() == [ + { + "in": "query string", + "loc": ["page_num"], + "msg": "field required", + "type": "value_error.missing", + } + ] + assert resp.status == 400 + assert resp.content_type == "application/json" + + +async def test_get_article_with_page(aiohttp_client, loop): + app = web.Application() + app.router.add_view("/article", ArticleViewWithPaginationGroup) + + client = await aiohttp_client(app) + + resp = await client.get("/article", params={"with_comments": 1, "page_num": 2}) + assert await resp.json() == {"page_num": 2, "page_size": 20, "with_comments": True} + assert resp.status == 200 + assert resp.content_type == "application/json" + + +async def test_get_article_with_page_and_page_size(aiohttp_client, loop): + app = web.Application() + app.router.add_view("/article", ArticleViewWithPaginationGroup) + + client = await aiohttp_client(app) + + resp = await client.get( + "/article", params={"with_comments": 1, "page_num": 1, "page_size": 10} + ) + assert await resp.json() == {"page_num": 1, "page_size": 10, "with_comments": True} + assert resp.status == 200 + assert resp.content_type == "application/json" + + +async def test_get_article_with_page_and_wrong_page_size(aiohttp_client, loop): + app = web.Application() + app.router.add_view("/article", ArticleViewWithPaginationGroup) + + client = await aiohttp_client(app) + + resp = await client.get( + "/article", params={"with_comments": 1, "page_num": 1, "page_size": "large"} + ) + assert await resp.json() == [ + { + "in": "query string", + "loc": ["page_size"], + "msg": "value is not a valid integer", + "type": "type_error.integer", + } + ] + assert resp.status == 400 + assert resp.content_type == "application/json" From 3648dde1ea839e6abb6b7c12c8716daae9a70a00 Mon Sep 17 00:00:00 2001 From: Vincent Maillol Date: Fri, 1 Oct 2021 08:51:50 +0200 Subject: [PATCH 25/26] Fix doc example --- README.rst | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.rst b/README.rst index 3c5eb03..b8bc2d7 100644 --- a/README.rst +++ b/README.rst @@ -324,6 +324,8 @@ If your method has lot of parameters you can group them together inside one or s .. code-block:: python3 + from aiohttp_pydantic.injectors import Group + class Pagination(Group): page_num: int = 1 page_size: int = 15 From 69fb5536358bbc4ce08bdf6655de6daa502b78b9 Mon Sep 17 00:00:00 2001 From: Vincent Maillol Date: Sat, 5 Feb 2022 10:28:05 +0100 Subject: [PATCH 26/26] Fix - Does not work with `from __future__ import annotations` --- aiohttp_pydantic/__init__.py | 2 +- aiohttp_pydantic/injectors.py | 23 +++++++++++++++------- aiohttp_pydantic/oas/view.py | 4 ++-- tests/test_group.py | 2 ++ tests/test_hook_to_custom_response.py | 2 ++ tests/test_inheritance.py | 2 ++ tests/test_oas/test_cmd/sample.py | 2 ++ tests/test_oas/test_cmd/test_cmd.py | 2 ++ tests/test_oas/test_docstring_parser.py | 2 ++ tests/test_oas/test_struct/test_info.py | 2 ++ tests/test_oas/test_struct/test_paths.py | 2 ++ tests/test_oas/test_struct/test_servers.py | 2 ++ tests/test_oas/test_view.py | 11 +++++++---- tests/test_parse_func_signature.py | 2 ++ tests/test_validation_body.py | 2 ++ tests/test_validation_header.py | 2 ++ tests/test_validation_path.py | 2 ++ tests/test_validation_query_string.py | 2 ++ 18 files changed, 54 insertions(+), 14 deletions(-) diff --git a/aiohttp_pydantic/__init__.py b/aiohttp_pydantic/__init__.py index 81d1052..da5ba94 100644 --- a/aiohttp_pydantic/__init__.py +++ b/aiohttp_pydantic/__init__.py @@ -1,5 +1,5 @@ from .view import PydanticView -__version__ = "1.12.0" +__version__ = "1.12.1" __all__ = ("PydanticView", "__version__") diff --git a/aiohttp_pydantic/injectors.py b/aiohttp_pydantic/injectors.py index 848474c..335adae 100644 --- a/aiohttp_pydantic/injectors.py +++ b/aiohttp_pydantic/injectors.py @@ -3,7 +3,7 @@ import typing from inspect import signature, getmro from json.decoder import JSONDecodeError from types import SimpleNamespace -from typing import Callable, Tuple, Literal, Type +from typing import Callable, Tuple, Literal, Type, get_type_hints from aiohttp.web_exceptions import HTTPBadRequest from aiohttp.web_request import BaseRequest @@ -201,13 +201,19 @@ def _get_group_signature(cls) -> Tuple[dict, dict]: mro = getmro(cls) for base in reversed(mro[: mro.index(Group)]): attrs = vars(base) + + # Use __annotations__ to know if an attribute is + # overwrite to remove the default value. for attr_name, type_ in base.__annotations__.items(): - sig[attr_name] = type_ if (default := attrs.get(attr_name)) is None: defaults.pop(attr_name, None) else: defaults[attr_name] = default + # Use get_type_hints to have postponed annotations. + for attr_name, type_ in get_type_hints(base).items(): + sig[attr_name] = type_ + return sig, defaults @@ -229,26 +235,29 @@ def _parse_func_signature( header_args = {} defaults = {} + annotations = get_type_hints(func) for param_name, param_spec in signature(func).parameters.items(): + if param_name == "self": continue if param_spec.annotation == param_spec.empty: raise RuntimeError(f"The parameter {param_name} must have an annotation") + annotation = annotations[param_name] if param_spec.default is not param_spec.empty: defaults[param_name] = param_spec.default if param_spec.kind is param_spec.POSITIONAL_ONLY: - path_args[param_name] = param_spec.annotation + path_args[param_name] = annotation elif param_spec.kind is param_spec.POSITIONAL_OR_KEYWORD: - if is_pydantic_base_model(param_spec.annotation): - body_args[param_name] = param_spec.annotation + if is_pydantic_base_model(annotation): + body_args[param_name] = annotation else: - qs_args[param_name] = param_spec.annotation + qs_args[param_name] = annotation elif param_spec.kind is param_spec.KEYWORD_ONLY: - header_args[param_name] = param_spec.annotation + header_args[param_name] = annotation else: raise RuntimeError(f"You cannot use {param_spec.VAR_POSITIONAL} parameters") diff --git a/aiohttp_pydantic/oas/view.py b/aiohttp_pydantic/oas/view.py index 2d10609..832c5db 100644 --- a/aiohttp_pydantic/oas/view.py +++ b/aiohttp_pydantic/oas/view.py @@ -1,7 +1,7 @@ import typing from inspect import getdoc from itertools import count -from typing import List, Type, Optional +from typing import List, Type, Optional, get_type_hints from aiohttp.web import Response, json_response from aiohttp.web_app import Application @@ -126,7 +126,7 @@ def _add_http_method_to_oas( ref_template="#/components/schemas/{model}" ) - return_type = handler.__annotations__.get("return") + return_type = get_type_hints(handler).get("return") if return_type is not None: _OASResponseBuilder(oas, oas_operation, status_code_descriptions).build( return_type diff --git a/tests/test_group.py b/tests/test_group.py index 24a903f..c734109 100644 --- a/tests/test_group.py +++ b/tests/test_group.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import pytest from aiohttp_pydantic.injectors import ( diff --git a/tests/test_hook_to_custom_response.py b/tests/test_hook_to_custom_response.py index c9aa6b5..dd3b5b5 100644 --- a/tests/test_hook_to_custom_response.py +++ b/tests/test_hook_to_custom_response.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from typing import Iterator, List, Optional from aiohttp import web diff --git a/tests/test_inheritance.py b/tests/test_inheritance.py index d759c0d..baa69ac 100644 --- a/tests/test_inheritance.py +++ b/tests/test_inheritance.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from typing import Any from aiohttp_pydantic import PydanticView diff --git a/tests/test_oas/test_cmd/sample.py b/tests/test_oas/test_cmd/sample.py index 2c24c7e..7662041 100644 --- a/tests/test_oas/test_cmd/sample.py +++ b/tests/test_oas/test_cmd/sample.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from aiohttp import web from aiohttp_pydantic import PydanticView diff --git a/tests/test_oas/test_cmd/test_cmd.py b/tests/test_oas/test_cmd/test_cmd.py index f9d78ca..1dd43e8 100644 --- a/tests/test_oas/test_cmd/test_cmd.py +++ b/tests/test_oas/test_cmd/test_cmd.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import argparse from textwrap import dedent from io import StringIO diff --git a/tests/test_oas/test_docstring_parser.py b/tests/test_oas/test_docstring_parser.py index aaf964f..77c69c7 100644 --- a/tests/test_oas/test_docstring_parser.py +++ b/tests/test_oas/test_docstring_parser.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from textwrap import dedent from aiohttp_pydantic.oas.docstring_parser import ( diff --git a/tests/test_oas/test_struct/test_info.py b/tests/test_oas/test_struct/test_info.py index 642072b..ce3a3c5 100644 --- a/tests/test_oas/test_struct/test_info.py +++ b/tests/test_oas/test_struct/test_info.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import pytest from aiohttp_pydantic.oas.struct import OpenApiSpec3 diff --git a/tests/test_oas/test_struct/test_paths.py b/tests/test_oas/test_struct/test_paths.py index 78453cd..ece2a70 100644 --- a/tests/test_oas/test_struct/test_paths.py +++ b/tests/test_oas/test_struct/test_paths.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from aiohttp_pydantic.oas.struct import OpenApiSpec3 diff --git a/tests/test_oas/test_struct/test_servers.py b/tests/test_oas/test_struct/test_servers.py index d2bb1a4..cccb9cb 100644 --- a/tests/test_oas/test_struct/test_servers.py +++ b/tests/test_oas/test_struct/test_servers.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import pytest from aiohttp_pydantic.oas.struct import OpenApiSpec3 diff --git a/tests/test_oas/test_view.py b/tests/test_oas/test_view.py index af9c338..83363ce 100644 --- a/tests/test_oas/test_view.py +++ b/tests/test_oas/test_view.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from enum import Enum from typing import List, Optional, Union, Literal from uuid import UUID @@ -385,15 +387,16 @@ async def test_generated_view_info_as_title(): } +class Pagination(Group): + page: int = 1 + page_size: int = 20 + + async def test_use_parameters_group_should_not_impact_the_oas(aiohttp_client): class PetCollectionView1(PydanticView): async def get(self, page: int = 1, page_size: int = 20) -> r200[List[Pet]]: return web.json_response() - class Pagination(Group): - page: int = 1 - page_size: int = 20 - class PetCollectionView2(PydanticView): async def get(self, pagination: Pagination) -> r200[List[Pet]]: return web.json_response() diff --git a/tests/test_parse_func_signature.py b/tests/test_parse_func_signature.py index 6e588e7..256e128 100644 --- a/tests/test_parse_func_signature.py +++ b/tests/test_parse_func_signature.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from uuid import UUID from pydantic import BaseModel diff --git a/tests/test_validation_body.py b/tests/test_validation_body.py index 6a7fdd7..6a03316 100644 --- a/tests/test_validation_body.py +++ b/tests/test_validation_body.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from typing import Iterator, List, Optional from aiohttp import web diff --git a/tests/test_validation_header.py b/tests/test_validation_header.py index 134c6ca..95d317a 100644 --- a/tests/test_validation_header.py +++ b/tests/test_validation_header.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import json from datetime import datetime from enum import Enum diff --git a/tests/test_validation_path.py b/tests/test_validation_path.py index bfa2016..ae8a453 100644 --- a/tests/test_validation_path.py +++ b/tests/test_validation_path.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from aiohttp import web from aiohttp_pydantic import PydanticView diff --git a/tests/test_validation_query_string.py b/tests/test_validation_query_string.py index 344cea0..d6041f1 100644 --- a/tests/test_validation_query_string.py +++ b/tests/test_validation_query_string.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from typing import Optional, List from pydantic import Field from aiohttp import web