From 6f2895e6e24b55e3e24fbf338b8cfc49a5135b9a Mon Sep 17 00:00:00 2001 From: p1c2u Date: Tue, 24 Feb 2026 02:19:07 +0000 Subject: [PATCH] Split CLI error controls into validation and subschema modes --- docs/cli.rst | 45 ++++++--- docs/index.rst | 4 + openapi_spec_validator/__main__.py | 87 +++++++++++++++-- openapi_spec_validator/validation/__init__.py | 2 + tests/integration/test_main.py | 94 ++++++++++++++++++- 5 files changed, 209 insertions(+), 23 deletions(-) diff --git a/docs/cli.rst b/docs/cli.rst index b058a1a..ee80ab2 100644 --- a/docs/cli.rst +++ b/docs/cli.rst @@ -23,27 +23,48 @@ CLI (Command Line Interface) docker run -v path/to/openapi.yaml:/openapi.yaml --rm pythonopenapi/openapi-spec-validator /openapi.yaml + Show all validation errors: + + .. code-block:: bash + + docker run -v path/to/openapi.yaml:/openapi.yaml --rm pythonopenapi/openapi-spec-validator --validation-errors all /openapi.yaml + + Show all validation errors and all subschema details: + + .. code-block:: bash + + docker run -v path/to/openapi.yaml:/openapi.yaml --rm pythonopenapi/openapi-spec-validator --validation-errors all --subschema-errors all /openapi.yaml + .. md-tab-item:: Python interpreter .. code-block:: bash python -m openapi_spec_validator openapi.yaml -.. code-block:: bash +.. code-block:: text - usage: openapi-spec-validator [-h] [--errors {best-match,all}] - [--schema {2.0,3.0.0,3.1.0,detect}] - filename + usage: openapi-spec-validator [-h] [--subschema-errors {best-match,all}] + [--validation-errors {first,all}] + [--errors {best-match,all}] [--schema {detect,2.0,3.0,3.1}] + [--version] file [file ...] positional arguments: - filename Absolute or relative path to file + file Validate specified file(s). options: -h, --help show this help message and exit - --errors {best-match,all} - Control error reporting. Defaults to "best- - match", use "all" to get all subschema - errors. - --schema {2.0,3.0.0,3.1.0,detect} - OpenAPI schema (default: detect) - + --subschema-errors {best-match,all} + Control subschema error details. Defaults to "best-match", + use "all" to get all subschema errors. + --validation-errors {first,all} + Control validation errors count. Defaults to "first", + use "all" to get all validation errors. + --errors {best-match,all}, --error {best-match,all} + Deprecated alias for --subschema-errors. + --schema {detect,2.0,3.0,3.1} + OpenAPI schema version (default: detect). + --version show program's version number and exit + +Legacy note: + ``--errors`` / ``--error`` are deprecated and emit warnings by default. + Set ``OPENAPI_SPEC_VALIDATOR_WARN_DEPRECATED=0`` to silence warnings. diff --git a/docs/index.rst b/docs/index.rst index 4ee615a..d1621c2 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -63,6 +63,10 @@ Usage docker run -v path/to/openapi.yaml:/openapi.yaml --rm pythonopenapi/openapi-spec-validator /openapi.yaml + .. code-block:: bash + + docker run -v path/to/openapi.yaml:/openapi.yaml --rm pythonopenapi/openapi-spec-validator --validation-errors all /openapi.yaml + .. md-tab-item:: Python interpreter .. code-block:: bash diff --git a/openapi_spec_validator/__main__.py b/openapi_spec_validator/__main__.py index 7ecc44c..9b7bdeb 100644 --- a/openapi_spec_validator/__main__.py +++ b/openapi_spec_validator/__main__.py @@ -1,4 +1,5 @@ import logging +import os import sys from argparse import ArgumentParser from collections.abc import Sequence @@ -9,10 +10,12 @@ from openapi_spec_validator import __version__ from openapi_spec_validator.readers import read_from_filename from openapi_spec_validator.readers import read_from_stdin +from openapi_spec_validator.shortcuts import get_validator_cls from openapi_spec_validator.shortcuts import validate from openapi_spec_validator.validation import OpenAPIV2SpecValidator from openapi_spec_validator.validation import OpenAPIV30SpecValidator from openapi_spec_validator.validation import OpenAPIV31SpecValidator +from openapi_spec_validator.validation import SpecValidator logger = logging.getLogger(__name__) logging.basicConfig( @@ -30,27 +33,42 @@ def print_error(filename: str, exc: Exception) -> None: def print_validationerror( - filename: str, exc: ValidationError, errors: str = "best-match" + filename: str, + exc: ValidationError, + subschema_errors: str = "best-match", + index: int | None = None, ) -> None: - print(f"{filename}: Validation Error: {exc}") + if index is None: + print(f"{filename}: Validation Error: {exc}") + else: + print(f"{filename}: Validation Error: [{index}] {exc}") if exc.cause: print("\n# Cause\n") print(exc.cause) if not exc.context: return - if errors == "all": + if subschema_errors == "all": print("\n\n# Due to one of those errors\n") print("\n\n\n".join("## " + str(e) for e in exc.context)) - elif errors == "best-match": + elif subschema_errors == "best-match": print("\n\n# Probably due to this subschema error\n") print("## " + str(best_match(exc.context))) if len(exc.context) > 1: print( f"\n({len(exc.context) - 1} more subschemas errors,", - "use --errors=all to see them.)", + "use --subschema-errors=all to see them.)", ) +def should_warn_deprecated() -> bool: + return os.getenv("OPENAPI_SPEC_VALIDATOR_WARN_DEPRECATED", "1") != "0" + + +def warn_deprecated(message: str) -> None: + if should_warn_deprecated(): + print(f"DeprecationWarning: {message}", file=sys.stderr) + + def main(args: Sequence[str] | None = None) -> None: parser = ArgumentParser(prog="openapi-spec-validator") parser.add_argument( @@ -59,12 +77,27 @@ def main(args: Sequence[str] | None = None) -> None: help="Validate specified file(s).", ) parser.add_argument( - "--errors", + "--subschema-errors", choices=("best-match", "all"), - default="best-match", - help="""Control error reporting. Defaults to "best-match", """ + default=None, + help="""Control subschema error details. Defaults to "best-match", """ """use "all" to get all subschema errors.""", ) + parser.add_argument( + "--validation-errors", + choices=("first", "all"), + default="first", + help="""Control validation errors count. Defaults to "first", """ + """use "all" to get all validation errors.""", + ) + parser.add_argument( + "--errors", + "--error", + dest="deprecated_subschema_errors", + choices=("best-match", "all"), + default=None, + help="Deprecated alias for --subschema-errors.", + ) parser.add_argument( "--schema", type=str, @@ -80,6 +113,22 @@ def main(args: Sequence[str] | None = None) -> None: ) args_parsed = parser.parse_args(args) + subschema_errors = args_parsed.subschema_errors + if args_parsed.deprecated_subschema_errors is not None: + if args_parsed.subschema_errors is None: + subschema_errors = args_parsed.deprecated_subschema_errors + warn_deprecated( + "--errors/--error is deprecated. " + "Use --subschema-errors instead." + ) + else: + warn_deprecated( + "--errors/--error is deprecated and ignored when " + "--subschema-errors is provided." + ) + if subschema_errors is None: + subschema_errors = "best-match" + for filename in args_parsed.file: # choose source reader = read_from_filename @@ -95,7 +144,7 @@ def main(args: Sequence[str] | None = None) -> None: sys.exit(1) # choose the validator - validators = { + validators: dict[str, type[SpecValidator] | None] = { "detect": None, "2.0": OpenAPIV2SpecValidator, "3.0": OpenAPIV30SpecValidator, @@ -108,9 +157,27 @@ def main(args: Sequence[str] | None = None) -> None: # validate try: + if args_parsed.validation_errors == "all": + if validator_cls is None: + validator_cls = get_validator_cls(spec) + validator = validator_cls(spec, base_uri=base_uri) + errors = list(validator.iter_errors()) + if errors: + for idx, err in enumerate(errors, start=1): + print_validationerror( + filename, + err, + subschema_errors, + index=idx, + ) + print(f"{filename}: {len(errors)} validation errors found") + sys.exit(1) + print_ok(filename) + continue + validate(spec, base_uri=base_uri, cls=validator_cls) except ValidationError as exc: - print_validationerror(filename, exc, args_parsed.errors) + print_validationerror(filename, exc, subschema_errors) sys.exit(1) except Exception as exc: print_error(filename, exc) diff --git a/openapi_spec_validator/validation/__init__.py b/openapi_spec_validator/validation/__init__.py index 3450616..d30e991 100644 --- a/openapi_spec_validator/validation/__init__.py +++ b/openapi_spec_validator/validation/__init__.py @@ -7,6 +7,7 @@ from openapi_spec_validator.validation.validators import ( OpenAPIV31SpecValidator, ) +from openapi_spec_validator.validation.validators import SpecValidator __all__ = [ "openapi_v2_spec_validator", @@ -18,6 +19,7 @@ "OpenAPIV3SpecValidator", "OpenAPIV30SpecValidator", "OpenAPIV31SpecValidator", + "SpecValidator", ] # v2.0 spec diff --git a/tests/integration/test_main.py b/tests/integration/test_main.py index 9125cdd..1ce4e41 100644 --- a/tests/integration/test_main.py +++ b/tests/integration/test_main.py @@ -101,7 +101,7 @@ def test_errors_on_missing_description_full(capsys): """An error is obviously printed given an empty schema.""" testargs = [ "./tests/integration/data/v3.0/missing-description.yaml", - "--errors=all", + "--subschema-errors=all", "--schema=3.0.0", ] with pytest.raises(SystemExit): @@ -221,6 +221,98 @@ def test_malformed_schema_stdin(capsys): assert "stdin: OK" not in out +def test_errors_all_lists_all_validation_errors(capsys): + spec_io = StringIO( + """ +openapi: 3.0.0 +""" + ) + + testargs = ["--validation-errors", "all", "--schema", "3.0.0", "-"] + with mock.patch("openapi_spec_validator.__main__.sys.stdin", spec_io): + with pytest.raises(SystemExit): + main(testargs) + + out, err = capsys.readouterr() + assert not err + assert "stdin: Validation Error: [1]" in out + assert "stdin: Validation Error: [2]" in out + assert "'info' is a required property" in out + assert "'paths' is a required property" in out + assert "stdin: 2 validation errors found" in out + + +def test_error_alias_controls_subschema_errors_and_warns(capsys): + testargs = [ + "./tests/integration/data/v3.0/missing-description.yaml", + "--error", + "all", + "--schema=3.0.0", + ] + with pytest.raises(SystemExit): + main(testargs) + + out, err = capsys.readouterr() + assert "'$ref' is a required property" in out + assert "validation errors found" not in out + assert ( + "DeprecationWarning: --errors/--error is deprecated. " + "Use --subschema-errors instead." + ) in err + + +def test_error_alias_warning_can_be_disabled(capsys): + testargs = [ + "./tests/integration/data/v3.0/missing-description.yaml", + "--error", + "all", + "--schema=3.0.0", + ] + with mock.patch.dict( + "openapi_spec_validator.__main__.os.environ", + {"OPENAPI_SPEC_VALIDATOR_WARN_DEPRECATED": "0"}, + clear=False, + ): + with pytest.raises(SystemExit): + main(testargs) + + out, err = capsys.readouterr() + assert "'$ref' is a required property" in out + assert not err + + +def test_deprecated_error_ignored_when_new_flag_used(capsys): + spec_io = StringIO( + """ +openapi: 3.0.0 +""" + ) + + testargs = [ + "--error", + "all", + "--subschema-errors", + "best-match", + "--validation-errors", + "all", + "--schema", + "3.0.0", + "-", + ] + with mock.patch("openapi_spec_validator.__main__.sys.stdin", spec_io): + with pytest.raises(SystemExit): + main(testargs) + + out, err = capsys.readouterr() + assert "stdin: Validation Error: [1]" in out + assert "# Probably due to this subschema error" not in out + assert ( + "DeprecationWarning: --errors/--error is deprecated and ignored when " + "--subschema-errors is provided." + ) in err + assert "stdin: 2 validation errors found" in out + + def test_version(capsys): """Test --version flag outputs correct version.""" testargs = ["--version"]