From b1212fdb10fab8500b67420fd02da329f05c6d93 Mon Sep 17 00:00:00 2001 From: David Seaward Date: Wed, 7 Sep 2022 18:52:29 +0100 Subject: [PATCH 1/4] Export title and version to openapi.yaml. Update tests to cover new functions. Add instruction for single-module testing to README. Signed-off-by: David Seaward --- README.rst | 13 ++- acceptable/openapi.py | 45 +++++++--- acceptable/tests/test_main.py | 139 ++++++++++++----------------- acceptable/tests/test_openapi.py | 76 ++++++++++++++++ examples/oas_testcase_openapi.yaml | 14 ++- tox.ini | 2 +- 6 files changed, 193 insertions(+), 96 deletions(-) create mode 100644 acceptable/tests/test_openapi.py diff --git a/README.rst b/README.rst index 39a3aaf..fd793ac 100644 --- a/README.rst +++ b/README.rst @@ -82,7 +82,6 @@ Acceptable will generate a JSON schema representation of the form for documentat To generate API metadata, you should add 'acceptable' to INSTALLED_APPS. This will provide an 'acceptable' management command:: - ./manage.py acceptable metadata > api.json # generate metadata And also:: @@ -90,7 +89,6 @@ And also:: ./manage.py acceptable api-version api.json # inspect the current version - Documentation (beta) -------------------- @@ -109,6 +107,7 @@ This markdown is designed to rendered to html by documentation-builder --base-directory docs + Includable Makefile ------------------- @@ -150,3 +149,13 @@ Development ----------- ``make test`` and ``make tox`` should run without errors. + +To run a single test module invoke:: + + python setup.py test --test-suite acceptable.tests.test_module + +or:: + + tox -epy38 -- --test-suite acceptable.tests.test_module + +...the latter runs "test_module" against Python 3.8 only. diff --git a/acceptable/openapi.py b/acceptable/openapi.py index 226b816..cd4807b 100644 --- a/acceptable/openapi.py +++ b/acceptable/openapi.py @@ -1,14 +1,15 @@ """ Helpers to translate acceptable metadata to OpenAPI specifications (OAS). """ - +from dataclasses import dataclass, field from typing import Any import yaml +from acceptable._service import APIMetadata -def _to_dict(source: Any): +def _to_dict(source: Any): if hasattr(source, "_to_dict"): return source._to_dict() # noqa elif type(source) == dict: @@ -21,17 +22,41 @@ def _to_dict(source: Any): for item in source: source_list.append(_to_dict(item)) return source_list + elif hasattr(source, "__dict__"): + source_dict = {} + for key, value in source.__dict__.items(): + source_dict[key] = _to_dict(value) + return source_dict else: return source -def dump(metadata, stream): +@dataclass +class OasInfo(object): + description: str = "" + version: str = "" + title: str = "" + tags: list = field(default_factory=lambda: []) + contact: dict = field(default_factory=lambda: {"name": "", "email": ""}) + + +@dataclass +class OasRoot31(object): + openapi: str = "3.1.0" + info: OasInfo = OasInfo() + servers: dict = field(default_factory=lambda: {}) + paths: dict = field(default_factory=lambda: {}) + components_schemas: dict = field(default_factory=lambda: {}) + + +def dump(metadata: APIMetadata, stream): + service_name = None + if len(metadata.services) == 1: + service_name = list(metadata.services.keys())[0] - # TODO: parse metadata as OpenAPI specification + oas = OasRoot31() + oas.info.title = service_name or "" + oas.info.version = metadata.current_version or "" - return yaml.safe_dump( - _to_dict(None), - stream, - default_flow_style=False, - encoding=None, - ) + oas_to_dict = _to_dict(oas) + return yaml.safe_dump(oas_to_dict, stream, default_flow_style=False, encoding=None, ) diff --git a/acceptable/tests/test_main.py b/acceptable/tests/test_main.py index 86b5e94..bbfd3c2 100644 --- a/acceptable/tests/test_main.py +++ b/acceptable/tests/test_main.py @@ -1,19 +1,19 @@ # Copyright 2017 Canonical Ltd. This software is licensed under the # GNU Lesser General Public License version 3 (see the file LICENSE). import argparse -from collections import OrderedDict import contextlib -from functools import partial import io import json import os import subprocess import sys import tempfile -import yaml +from collections import OrderedDict +from functools import partial -import testtools import fixtures +import testtools +import yaml from acceptable import __main__ as main from acceptable import get_metadata @@ -148,7 +148,7 @@ def my_view(): 'request_schema': {'type': 'object'}, 'response_schema': {'type': 'object'}, 'params_schema': {'type': 'object'}, - 'introduced_at': 1, + 'introduced_at': 1, 'title': 'Root', } } @@ -234,7 +234,7 @@ def my_view(): 'request_schema': {'type': 'object'}, 'response_schema': {'type': 'object'}, 'params_schema': {'type': 'object', 'properties': {'test': {'type': 'string'}}}, - 'introduced_at': 1, + 'introduced_at': 1, 'title': 'Root', } } @@ -299,7 +299,7 @@ def metadata(self): }, 'request_schema': {'request_schema': 1}, 'response_schema': {'response_schema': 2}, - 'introduced_at': 1, + 'introduced_at': 1, 'title': 'Api1', } return metadata @@ -349,7 +349,7 @@ def metadata(self): }, 'request_schema': {'request_schema': 1}, 'response_schema': {'response_schema': 2}, - 'introduced_at': 1, + 'introduced_at': 1, 'title': 'Api1', } metadata['group']['apis']['api2'] = { @@ -363,7 +363,7 @@ def metadata(self): }, 'request_schema': {'request_schema': 1}, 'response_schema': {'response_schema': 2}, - 'introduced_at': 1, + 'introduced_at': 1, 'title': 'Api2', } return metadata @@ -376,14 +376,7 @@ def test_render_markdown_success(self): iterator = main.render_markdown(self.metadata(), args) output = OrderedDict((str(k), v) for k, v in iterator) - self.assertEqual(set([ - 'en/group.md', - 'en/index.md', - 'en/metadata.yaml', - 'metadata.yaml', - ]), - set(output), - ) + self.assertEqual({'en/group.md', 'en/index.md', 'en/metadata.yaml', 'metadata.yaml'}, set(output)) top_level_md = yaml.safe_load(output['metadata.yaml']) self.assertEqual( @@ -393,11 +386,11 @@ def test_render_markdown_success(self): md = yaml.safe_load(output['en/metadata.yaml']) self.assertEqual({ - 'navigation': [ - {'location': 'index.md', 'title': 'Index'}, - {'location': 'group.md', 'title': 'Group'}, - ], - }, + 'navigation': [ + {'location': 'index.md', 'title': 'Index'}, + {'location': 'group.md', 'title': 'Group'}, + ], + }, md ) @@ -410,24 +403,17 @@ def test_render_markdown_undocumented(self): iterator = main.render_markdown(m, args) output = OrderedDict((str(k), v) for k, v in iterator) - self.assertEqual(set([ - 'en/group.md', - 'en/index.md', - 'en/metadata.yaml', - 'metadata.yaml', - ]), - set(output), - ) + self.assertEqual({'en/group.md', 'en/index.md', 'en/metadata.yaml', 'metadata.yaml'}, set(output)) self.assertNotIn('api2', output['en/group.md']) md = yaml.safe_load(output['en/metadata.yaml']) self.assertEqual({ - 'navigation': [ - {'location': 'index.md', 'title': 'Index'}, - {'location': 'group.md', 'title': 'Group'}, - ], - }, + 'navigation': [ + {'location': 'index.md', 'title': 'Index'}, + {'location': 'group.md', 'title': 'Group'}, + ], + }, md ) @@ -440,22 +426,15 @@ def test_render_markdown_deprecated_at(self): iterator = main.render_markdown(m, args) output = OrderedDict((str(k), v) for k, v in iterator) - self.assertEqual(set([ - 'en/group.md', - 'en/index.md', - 'en/metadata.yaml', - 'metadata.yaml', - ]), - set(output), - ) + self.assertEqual({'en/group.md', 'en/index.md', 'en/metadata.yaml', 'metadata.yaml'}, set(output)) md = yaml.safe_load(output['en/metadata.yaml']) self.assertEqual({ - 'navigation': [ - {'location': 'index.md', 'title': 'Index'}, - {'location': 'group.md', 'title': 'Group'}, - ], - }, + 'navigation': [ + {'location': 'index.md', 'title': 'Index'}, + {'location': 'group.md', 'title': 'Group'}, + ], + }, md ) @@ -472,15 +451,8 @@ def test_render_markdown_multiple_groups(self): iterator = main.render_markdown(metadata, args) output = OrderedDict((str(k), v) for k, v in iterator) - self.assertEqual(set([ - 'en/group.md', - 'en/group2.md', - 'en/index.md', - 'en/metadata.yaml', - 'metadata.yaml', - ]), - set(output), - ) + self.assertEqual({'en/group.md', 'en/group2.md', 'en/index.md', 'en/metadata.yaml', 'metadata.yaml'}, + set(output)) top_level_md = yaml.safe_load(output['metadata.yaml']) self.assertEqual( @@ -490,12 +462,12 @@ def test_render_markdown_multiple_groups(self): md = yaml.safe_load(output['en/metadata.yaml']) self.assertEqual({ - 'navigation': [ - {'location': 'index.md', 'title': 'Index'}, - {'location': 'group.md', 'title': 'Group'}, - {'location': 'group2.md', 'title': 'Group2'}, - ], - }, + 'navigation': [ + {'location': 'index.md', 'title': 'Index'}, + {'location': 'group.md', 'title': 'Group'}, + {'location': 'group2.md', 'title': 'Group2'}, + ], + }, md ) @@ -513,14 +485,7 @@ def test_render_markdown_group_omitted_with_undocumented(self): iterator = main.render_markdown(metadata, args) output = OrderedDict((str(k), v) for k, v in iterator) - self.assertEqual(set([ - 'en/group.md', - 'en/index.md', - 'en/metadata.yaml', - 'metadata.yaml', - ]), - set(output), - ) + self.assertEqual({'en/group.md', 'en/index.md', 'en/metadata.yaml', 'metadata.yaml'}, set(output)) top_level_md = yaml.safe_load(output['metadata.yaml']) self.assertEqual( @@ -530,11 +495,11 @@ def test_render_markdown_group_omitted_with_undocumented(self): md = yaml.safe_load(output['en/metadata.yaml']) self.assertEqual({ - 'navigation': [ - {'location': 'index.md', 'title': 'Index'}, - {'location': 'group.md', 'title': 'Group'}, - ], - }, + 'navigation': [ + {'location': 'index.md', 'title': 'Index'}, + {'location': 'group.md', 'title': 'Group'}, + ], + }, md ) @@ -612,10 +577,19 @@ def test_render_cmd_with_documentation_builder(self): ' Documentation: API foo at response_schema.foo_result.introduced_at'), ] -EXPECTED_OPENAPI_RESULT = [ - "null\n", - "...\n", -] +EXPECTED_OPENAPI_RESULT = """components_schemas: {} +info: + contact: + email: '' + name: '' + description: '' + tags: [] + title: OpenApiSample + version: 5 +openapi: 3.1.0 +paths: {} +servers: {} +""" class LintTests(testtools.TestCase): @@ -655,7 +629,10 @@ def test_openapi_output(self): # And the OpenAPI file contains the expected value with open("examples/oas_testcase_openapi.yaml", "r") as f: - self.assertListEqual(EXPECTED_OPENAPI_RESULT, f.readlines()) + result = f.readlines() + + expected = EXPECTED_OPENAPI_RESULT.splitlines(keepends=True) + self.assertListEqual(expected, result) # And we implicitly assume the files have not changed diff --git a/acceptable/tests/test_openapi.py b/acceptable/tests/test_openapi.py new file mode 100644 index 0000000..88e62be --- /dev/null +++ b/acceptable/tests/test_openapi.py @@ -0,0 +1,76 @@ +# Copyright 2022 Canonical Ltd. This software is licensed under the +# GNU Lesser General Public License version 3 (see the file LICENSE). +from dataclasses import dataclass + +import testtools + +from acceptable import openapi +from acceptable._service import APIMetadata + +EXPECTED_EMPTY_METADATA = """components_schemas: {} +info: + contact: + email: '' + name: '' + description: '' + tags: [] + title: '' + version: '' +openapi: 3.1.0 +paths: {} +servers: {} +""" + + +@dataclass +class SampleWithImplicitDunderDict(object): + value: int = 42 + + +@dataclass +class SampleWithToDictMethod(object): + value: int = 42 + + def _to_dict(self): + return {"sample": self.value} + + +class ToDictTests(testtools.TestCase): + + @staticmethod + def test_convert_sample_with_to_dict_method_calls_method(): + result = openapi._to_dict(SampleWithToDictMethod()) + assert {"sample": 42} == result + + @staticmethod + def test_convert_dict_returns_new_dict(): + source = {"foo": "bar"} + result = openapi._to_dict(source) + assert source == result + assert id(source) != id(result) + + @staticmethod + def test_convert_list_returns_new_list(): + source = ["fizz", "buzz"] + result = openapi._to_dict(source) + assert source == result + assert id(source) != id(result) + + @staticmethod + def test_convert_sample_with_dunder_dict_returns_dunder_value(): + result = openapi._to_dict(SampleWithImplicitDunderDict()) + assert {"value": 42} == result + + @staticmethod + def test_convert_str_returns_same_value(): + result = openapi._to_dict("beeblebrox") + assert "beeblebrox" == result + + +class OpenApiTests(testtools.TestCase): + + def test_dump_of_empty_metadata(self): + metadata = APIMetadata() + result = openapi.dump(metadata, None).splitlines(keepends=False) + expected = EXPECTED_EMPTY_METADATA.splitlines(keepends=False) + self.assertListEqual(expected, result) diff --git a/examples/oas_testcase_openapi.yaml b/examples/oas_testcase_openapi.yaml index 6d800ee..ac97d35 100644 --- a/examples/oas_testcase_openapi.yaml +++ b/examples/oas_testcase_openapi.yaml @@ -1,2 +1,12 @@ -null -... +components_schemas: {} +info: + contact: + email: '' + name: '' + description: '' + tags: [] + title: OpenApiSample + version: 5 +openapi: 3.1.0 +paths: {} +servers: {} diff --git a/tox.ini b/tox.ini index c352018..0d76291 100644 --- a/tox.ini +++ b/tox.ini @@ -15,7 +15,7 @@ extras = flask django commands = - coverage run --source acceptable --omit "acceptable/tests/*" setup.py test + coverage run --source acceptable --omit "acceptable/tests/*" setup.py test {posargs} passenv = TRAVIS TRAVIS_BRANCH From 0026ee8496f06f84635d1a52bab692d1655d9455 Mon Sep 17 00:00:00 2001 From: David Seaward Date: Thu, 8 Sep 2022 17:27:06 +0100 Subject: [PATCH 2/4] Use list comprehensions in _to_dict. Signed-off-by: David Seaward --- acceptable/openapi.py | 15 +++------------ 1 file changed, 3 insertions(+), 12 deletions(-) diff --git a/acceptable/openapi.py b/acceptable/openapi.py index cd4807b..72ff09e 100644 --- a/acceptable/openapi.py +++ b/acceptable/openapi.py @@ -13,20 +13,11 @@ def _to_dict(source: Any): if hasattr(source, "_to_dict"): return source._to_dict() # noqa elif type(source) == dict: - source_dict = {} - for key, value in source.items(): - source_dict[key] = _to_dict(value) - return source_dict + return {key: _to_dict(value) for key, value in source.items()} elif type(source) == list: - source_list = [] - for item in source: - source_list.append(_to_dict(item)) - return source_list + return [_to_dict(value) for value in source] elif hasattr(source, "__dict__"): - source_dict = {} - for key, value in source.__dict__.items(): - source_dict[key] = _to_dict(value) - return source_dict + return {key: _to_dict(value) for key, value in source.__dict__.items()} else: return source From 9987836ae693b99ee6c97c9398c99418711ca464 Mon Sep 17 00:00:00 2001 From: David Seaward Date: Thu, 8 Sep 2022 17:30:48 +0100 Subject: [PATCH 3/4] Tidy up openapi.dump method. Signed-off-by: David Seaward --- acceptable/openapi.py | 5 ++--- acceptable/tests/test_openapi.py | 2 +- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/acceptable/openapi.py b/acceptable/openapi.py index 72ff09e..d3f4497 100644 --- a/acceptable/openapi.py +++ b/acceptable/openapi.py @@ -40,7 +40,7 @@ class OasRoot31(object): components_schemas: dict = field(default_factory=lambda: {}) -def dump(metadata: APIMetadata, stream): +def dump(metadata: APIMetadata, stream=None): service_name = None if len(metadata.services) == 1: service_name = list(metadata.services.keys())[0] @@ -49,5 +49,4 @@ def dump(metadata: APIMetadata, stream): oas.info.title = service_name or "" oas.info.version = metadata.current_version or "" - oas_to_dict = _to_dict(oas) - return yaml.safe_dump(oas_to_dict, stream, default_flow_style=False, encoding=None, ) + return yaml.safe_dump(_to_dict(oas), stream, default_flow_style=False, encoding=None) diff --git a/acceptable/tests/test_openapi.py b/acceptable/tests/test_openapi.py index 88e62be..1c1c93d 100644 --- a/acceptable/tests/test_openapi.py +++ b/acceptable/tests/test_openapi.py @@ -71,6 +71,6 @@ class OpenApiTests(testtools.TestCase): def test_dump_of_empty_metadata(self): metadata = APIMetadata() - result = openapi.dump(metadata, None).splitlines(keepends=False) + result = openapi.dump(metadata).splitlines(keepends=False) expected = EXPECTED_EMPTY_METADATA.splitlines(keepends=False) self.assertListEqual(expected, result) From 6755c08f376a9ab1dee98f618c32b5df674b7ef5 Mon Sep 17 00:00:00 2001 From: David Seaward Date: Thu, 8 Sep 2022 17:37:45 +0100 Subject: [PATCH 4/4] Replace hard-coded expected value with a file. Signed-off-by: David Seaward --- acceptable/tests/test_main.py | 22 +++++----------------- examples/oas_expected.yaml | 12 ++++++++++++ 2 files changed, 17 insertions(+), 17 deletions(-) create mode 100644 examples/oas_expected.yaml diff --git a/acceptable/tests/test_main.py b/acceptable/tests/test_main.py index bbfd3c2..f084eb3 100644 --- a/acceptable/tests/test_main.py +++ b/acceptable/tests/test_main.py @@ -577,20 +577,6 @@ def test_render_cmd_with_documentation_builder(self): ' Documentation: API foo at response_schema.foo_result.introduced_at'), ] -EXPECTED_OPENAPI_RESULT = """components_schemas: {} -info: - contact: - email: '' - name: '' - description: '' - tags: [] - title: OpenApiSample - version: 5 -openapi: 3.1.0 -paths: {} -servers: {} -""" - class LintTests(testtools.TestCase): @@ -628,10 +614,12 @@ def test_openapi_output(self): self.assertEqual([], output.getvalue().splitlines()) # And the OpenAPI file contains the expected value - with open("examples/oas_testcase_openapi.yaml", "r") as f: - result = f.readlines() + with open("examples/oas_testcase_openapi.yaml", "r") as _result: + result = _result.readlines() + + with open("examples/oas_expected.yaml", "r") as _expected: + expected = _expected.readlines() - expected = EXPECTED_OPENAPI_RESULT.splitlines(keepends=True) self.assertListEqual(expected, result) # And we implicitly assume the files have not changed diff --git a/examples/oas_expected.yaml b/examples/oas_expected.yaml new file mode 100644 index 0000000..ac97d35 --- /dev/null +++ b/examples/oas_expected.yaml @@ -0,0 +1,12 @@ +components_schemas: {} +info: + contact: + email: '' + name: '' + description: '' + tags: [] + title: OpenApiSample + version: 5 +openapi: 3.1.0 +paths: {} +servers: {}