From d9217abd63d88f6ef51d3d909a45459a643b3399 Mon Sep 17 00:00:00 2001 From: Rick Porter Date: Thu, 3 Sep 2026 20:23:08 -0400 Subject: [PATCH 1/2] Add 'layout check --extra-props' to flag commands/operations with extra properties --- openapi_spec_tools/cli/layout.py | 8 +++++++ openapi_spec_tools/layout/utils.py | 25 ++++++++++++++++++++++ tests/assets/layout_bad.yaml | 19 +++++++++++++++++ tests/cli/test_layout.py | 34 +++++++++++++++++++++++++----- tests/layout/test_layout_utils.py | 31 +++++++++++++++++++++++++++ 5 files changed, 112 insertions(+), 5 deletions(-) diff --git a/openapi_spec_tools/cli/layout.py b/openapi_spec_tools/cli/layout.py index e23f5e6..fd3919e 100755 --- a/openapi_spec_tools/cli/layout.py +++ b/openapi_spec_tools/cli/layout.py @@ -30,6 +30,7 @@ from openapi_spec_tools.layout.utils import file_to_tree from openapi_spec_tools.layout.utils import operation_duplicates from openapi_spec_tools.layout.utils import operation_order +from openapi_spec_tools.layout.utils import subcommand_extra_properties from openapi_spec_tools.layout.utils import subcommand_missing_properties from openapi_spec_tools.layout.utils import subcommand_order from openapi_spec_tools.layout.utils import subcommand_references @@ -55,6 +56,7 @@ def layout_check_format( references: Annotated[bool, typer.Option(help="Check for missing and unused subcommands")] = True, sub_order: Annotated[bool, typer.Option(help="Check the sub-command order")] = True, missing_props: Annotated[bool, typer.Option(help="Check for missing properties")] = True, + extra_props: Annotated[bool, typer.Option(help="Check for any extra properties")] = False, op_dups: Annotated[bool, typer.Option(help="Check for duplicate names in sub-commands")] = True, op_order: Annotated[bool, typer.Option(help="Check the operations order within each sub-command")] = True, pagination: Annotated[bool, typer.Option(help="Check the pagination parameters for issues")] = True, @@ -90,6 +92,12 @@ def _dict_to_str(errors: dict[str, str], sep=SEP) -> str: typer.echo(f"Sub-commands have missing properties:{_dict_to_str(errors)}") result = 1 + if extra_props: + errors = subcommand_extra_properties(data) + if errors: + typer.echo(f"Commands have extra properties:{_dict_to_str(errors)}") + result = 1 + if op_dups: errors = operation_duplicates(data) if errors: diff --git a/openapi_spec_tools/layout/utils.py b/openapi_spec_tools/layout/utils.py index e5cc4f2..2933fbf 100644 --- a/openapi_spec_tools/layout/utils.py +++ b/openapi_spec_tools/layout/utils.py @@ -203,6 +203,31 @@ def subcommand_missing_properties(data: dict[str, Any]) -> dict[str, str]: return errors +def subcommand_extra_properties(data: dict[str, Any]) -> dict[str, str]: + """Look for missing properties in the sub-commands.""" + errors = {} + commands = CommandField.values() + operations = OperationField.values() + for sub_name, _sub_data in data.items(): + sub_data = deepcopy(_sub_data or {}) + extra = [] + + # check top-level fields + extra.extend([k for k in sub_data.keys() if k not in commands]) + + # check each operations + for index, op_data in enumerate(sub_data.get(CommandField.OPERATIONS, [])): + op_extra = [k for k in op_data.keys() if k not in operations] + if op_extra: + identifier = op_data.get(OperationField.NAME) or f"operation[{index}]" + extra.append(f"{identifier}: {', '.join(op_extra)}") + + if extra: + errors[sub_name] = ", ".join(extra) + + return errors + + def operation_duplicates(data: dict[str, Any]) -> dict[str, Any]: """Look for command operations with redundant names (within each command).""" errors = {} diff --git a/tests/assets/layout_bad.yaml b/tests/assets/layout_bad.yaml index b98202f..80c4c2a 100644 --- a/tests/assets/layout_bad.yaml +++ b/tests/assets/layout_bad.yaml @@ -9,6 +9,8 @@ main: subcommandId: veterinarians - name: shows subcommandId: dog_shows + - name: walkers + subcommandId: walkers pets: description: Manage your pets @@ -61,3 +63,20 @@ veterinarians: operations: - name: add - name: delete + +walkers: + description: Manage dog walkers + foo: bar + operations: + - name: list + operationId: op_1 + sna: foo + bugIds: abc + - name: show + another: + this: 1 + that: one + thing: + - 1 + - 2 + operationId: op_2 diff --git a/tests/cli/test_layout.py b/tests/cli/test_layout.py index 0da397b..4f26f0e 100644 --- a/tests/cli/test_layout.py +++ b/tests/cli/test_layout.py @@ -30,18 +30,22 @@ Sub-commands are misordered: owners < pets_health """ -ERR_OPS_PROPS = """\ +ERR_MISSING_PROPS = """\ Sub-commands have missing properties: owners: description, operations veterinarians: add operationId, subcommandId, or reference, delete operationId, subcommandId, or reference """ +ERR_EXTRA_PROPS ="""\ +Commands have extra properties: + walkers: foo, list: sna, show: another, thing +""" ERR_OPS_DUPES = """\ Duplicate operations in sub-commands: shelters: list at 0, 2 """ ERR_OPS_ORDER = """\ Sub-command operation orders should be: - main: owners, pet, shows, vets + main: owners, pet, shows, vets, walkers pets: create, delete, examine, health, update shelters: list, list, rescue """ @@ -81,7 +85,22 @@ def args_disabled(updates: dict[str, Any]) -> dict[str, Any]: ERR_SUB_MISSIING, ERR_SUB_UNUSED, ERR_SUB_ORDER, - ERR_OPS_PROPS, + ERR_MISSING_PROPS, + ERR_OPS_DUPES, + ERR_OPS_ORDER, + ERR_PAGINATION, + ERR_HARDCODED, + ]), + id="default" + ), + pytest.param( + {"filename":BAD_LAYOUT_FILE, "extra_props": True}, + "".join([ + ERR_SUB_MISSIING, + ERR_SUB_UNUSED, + ERR_SUB_ORDER, + ERR_MISSING_PROPS, + ERR_EXTRA_PROPS, ERR_OPS_DUPES, ERR_OPS_ORDER, ERR_PAGINATION, @@ -101,8 +120,13 @@ def args_disabled(updates: dict[str, Any]) -> dict[str, Any]: ), pytest.param( args_disabled({"missing_props": True}), - ERR_OPS_PROPS, - id="ops-props", + ERR_MISSING_PROPS, + id="ops-missing", + ), + pytest.param( + args_disabled({"extra_props": True}), + ERR_EXTRA_PROPS, + id="ops-extra", ), pytest.param( args_disabled({"op_dups": True}), diff --git a/tests/layout/test_layout_utils.py b/tests/layout/test_layout_utils.py index 27bef93..9e120eb 100644 --- a/tests/layout/test_layout_utils.py +++ b/tests/layout/test_layout_utils.py @@ -17,6 +17,7 @@ from openapi_spec_tools.layout.utils import parse_pagination from openapi_spec_tools.layout.utils import parse_to_tree from openapi_spec_tools.layout.utils import path_to_parts +from openapi_spec_tools.layout.utils import subcommand_extra_properties from openapi_spec_tools.layout.utils import subcommand_missing_properties from openapi_spec_tools.layout.utils import subcommand_order from openapi_spec_tools.layout.utils import subcommand_references @@ -78,6 +79,36 @@ def test_open_layout() -> None: def test_missing_properties(data, expected) -> None: assert expected == subcommand_missing_properties(data) +@pytest.mark.parametrize( + ["data", "expected"], + [ + pytest.param({}, {}, id="empty"), + pytest.param({"cmd": {DESC: "a"}}, {}, id="none"), + pytest.param({"cmd": {DESC: "a", "other": 1}}, {"cmd": "other"}, id="cmd-single"), + pytest.param( + {"cmd1": {DESC: "a", "other": 1}, "cmd2": {"more": "props"}}, + {"cmd1": "other", "cmd2": "more"}, + id="cmd-double", + ), + pytest.param( + {"cmd": {OPS: [{NAME: "me", "other": 1}]}}, + {"cmd": "me: other"}, + id="op-single", + ), + pytest.param( + {"cmd": {OPS: [{NAME: "me", "myself": 1, "eye": "blue"}]}}, + {"cmd": "me: myself, eye"}, + id="op-multi", + ), + pytest.param( + {"sna": {OPS: [{"foo": "bar"}, {NAME: "a", "extra": "prop"}]}, "foo": {"this": "that"}}, + {"sna": "operation[0]: foo, a: extra", "foo": "this"}, + id="complex", + ), + ], +) +def test_extra_properties(data, expected) -> None: + assert expected == subcommand_extra_properties(data) @pytest.mark.parametrize( ["data", "expected"], From d0aa95334e636d263916ebb6dad2a8bb6b39313f Mon Sep 17 00:00:00 2001 From: Rick Porter Date: Fri, 4 Sep 2026 09:34:14 -0400 Subject: [PATCH 2/2] Test coverage to check extra without any errors --- tests/cli/test_layout.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/cli/test_layout.py b/tests/cli/test_layout.py index 4f26f0e..f496d30 100644 --- a/tests/cli/test_layout.py +++ b/tests/cli/test_layout.py @@ -166,7 +166,7 @@ def test_layout_check_format_success() -> None: mock.patch('sys.stdout', new_callable=StringIo) as mock_stdout, ): filename = asset_filename("layout_pets.yaml") - layout_check_format(filename=filename) + layout_check_format(filename=filename, extra_props=True) output = mock_stdout.getvalue() assert f"No errors found in {filename}\n" == output