From fa7e8d914bad3a76932244c59f0247cf9a09cd11 Mon Sep 17 00:00:00 2001 From: Vincent Maillol Date: Sun, 22 Aug 2021 14:32:19 +0200 Subject: [PATCH] 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": {