diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..9196e34d --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,148 @@ +# AGENTS.md +Guidance for coding agents working in `openapi-spec-validator`. + +## Project Snapshot +- Language: Python (3.10-3.14) +- Tooling: Poetry, pytest, mypy, black, isort, flake8, deptry, pre-commit, tox +- Main package: `openapi_spec_validator/` +- Tests: `tests/integration/`, `tests/bench/` +- Docs: Sphinx in `docs/` + +## Setup +- Keep venv in-project: `poetry config virtualenvs.in-project true` +- Install dev deps: `poetry install --with dev` +- Install docs deps: `poetry install --with docs` + +## Build Commands +- Build artifacts: `poetry build` +- Legacy build path: `make dist-build` +- Clean build/test artifacts: `make cleanup` + +## Test Commands +- Full tests: `poetry run pytest` +- Multi-Python matrix locally: `tox` +- One file: `poetry run pytest tests/integration/test_main.py` +- One test function: `poetry run pytest tests/integration/test_main.py::test_version` +- One parametrized case: + `poetry run pytest 'tests/integration/validation/test_validators.py::TestLocalOpenAPIv30Validator::test_valid[petstore.yaml]'` +- By keyword: `poetry run pytest -k "schema_v31"` +- Exclude network tests: `poetry run pytest -m "not network"` +- Run only network tests: `poetry run pytest -m network` +- Fast focused test without default addopts (no coverage/junit): + `poetry run pytest -o addopts='' tests/integration/test_main.py::test_version` + +## Lint, Format, Type, Dependencies +- Format: `poetry run black . && poetry run isort .` +- Format check only: `poetry run black --check . && poetry run isort --check-only .` +- Lint: `poetry run flake8` +- Types: `poetry run mypy` +- Dependency check: `poetry run deptry .` +- Pre-commit setup: `pre-commit install` +- Run all hooks: `pre-commit run --all-files` + +## Docs +- CI-equivalent docs build: + `poetry run python -m sphinx -T -b html -d docs/_build/doctrees -D language=en docs docs/_build/html -n -W` + +## Style Rules (from repo config + code) + +### Formatting +- Black is authoritative formatter. +- Line length is 79 (`tool.black.line-length = 79`, flake8 79). +- isort uses `profile = black` and `force_single_line = true`. +- Keep one imported symbol per line for `from x import y` style blocks. + +### Imports +- Use absolute imports from `openapi_spec_validator...`. +- Order import groups: stdlib, third-party, first-party. +- Avoid wildcard imports. +- Keep imports explicit and deterministic under isort. + +### Typing +- Mypy is strict (`[tool.mypy] strict = true`). +- Add annotations to all new/modified functions and methods. +- Prefer built-in generics (`list[str]`, `dict[str, int]`) and `X | None`. +- Avoid broad `Any`; if unavoidable, keep scope minimal. +- Existing ignores are for external libs only; do not add broad ignores casually. + +### Naming +- Modules/files: `snake_case`. +- Variables/functions: `snake_case`. +- Classes: `PascalCase`. +- Constants: `UPPER_SNAKE_CASE`. +- Test functions: `test_*` and behavior-focused names. + +### Error Handling +- Raise specific exceptions in library code. +- Preserve public exception behavior unless change is intentional and tested. +- CLI in `openapi_spec_validator/__main__.py` currently uses: + - exit code 1 for read/validation failures + - exit code 2 for unexpected runtime failures +- Keep deprecation warnings consistent with current message patterns. +- Do not discard useful validation context when propagating errors. + +### Tests and Markers +- Use pytest assertions directly (`assert ...`). +- Reuse helpers in `tests/integration/conftest.py`. +- Mark network-dependent tests with `@pytest.mark.network`. +- Prefer integration tests near affected behavior (`validation`, `shortcuts`, `versions`, CLI). +- If behavior changes, add/adjust tests in the same PR. + +## CI Expectations +- Main CI test workflow runs on Python 3.10-3.14 and ubuntu/windows. +- Core checks are: + 1. `poetry run pytest` + 2. `poetry run mypy` + 3. `poetry run deptry .` +- Pre-commit hooks include: pyupgrade (`--py310-plus`), black, isort, flake8. +- Docs CI installs `--with docs` and runs Sphinx with `-n -W` (warnings are failures). + +## Compatibility Notes +- Project keeps deprecated compatibility paths (e.g., old flags and shortcuts). +- Avoid removing aliases or changing warning behavior without explicit instruction. +- Keep CLI/user-facing strings stable unless tests are updated accordingly. + +## Cursor/Copilot Instructions Check +- `.cursor/rules/`: not present +- `.cursorrules`: not present +- `.github/copilot-instructions.md`: not present +- If these files appear later, treat them as higher-priority agent instructions and update this file. + +## Agent Working Agreement +- Keep diffs minimal and scoped. +- Do not modify unrelated files. +- Prefer targeted tests first, then full suite when needed. +- Run formatter/lint/type checks for code changes before finishing. +- Maintain backward compatibility unless task explicitly requests breaking change. + +## Handy Paths +- Package entrypoint: `openapi_spec_validator/__main__.py` +- API shortcuts: `openapi_spec_validator/shortcuts.py` +- Validators: `openapi_spec_validator/validation/validators.py` +- Reader utilities: `openapi_spec_validator/readers.py` +- Integration tests: `tests/integration/` +- Pyproject config: `pyproject.toml` +- Tox config: `tox.ini` +- Pre-commit config: `.pre-commit-config.yaml` + +## Quick Local Validation Recipe +Run this sequence before handoff: +1. `poetry run black . && poetry run isort .` +2. `poetry run flake8` +3. `poetry run mypy` +4. `poetry run pytest -m "not network"` +5. Add targeted network test runs only if your change touches URL/network behavior. + +## Practical Execution Notes +- Pytest defaults from `pyproject.toml` include coverage and junit outputs. +- For quick iterations, prefer `-o addopts=''` with a specific node id. +- Keep command output readable; avoid noisy full-suite runs unless needed. +- For CLI behavior changes, prioritize tests in `tests/integration/test_main.py`. +- For validator behavior changes, prioritize `tests/integration/validation/`. +- For version detection changes, prioritize `tests/integration/test_versions.py`. + +## Commit/PR Hygiene for Agents +- Keep changes scoped to the requested task. +- Do not bundle formatting-only churn with behavior changes unless requested. +- Mention any intentionally preserved deprecated behavior in PR notes. +- If you change user-visible messages, update tests in the same change. diff --git a/Dockerfile b/Dockerfile index 5062ed2f..c4b8ef4b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -ARG OPENAPI_SPEC_VALIDATOR_VERSION=0.8.0 +ARG OPENAPI_SPEC_VALIDATOR_VERSION=0.8.1 FROM python:3.14.3-alpine as builder diff --git a/README.rst b/README.rst index bd997931..8b192517 100644 --- a/README.rst +++ b/README.rst @@ -85,7 +85,7 @@ pre-commit hook repos: - repo: https://github.com/python-openapi/openapi-spec-validator - rev: 0.8.0 # The version to use or 'master' for latest + rev: 0.8.1 # The version to use or 'master' for latest hooks: - id: openapi-spec-validator diff --git a/docs/cli.rst b/docs/cli.rst index b058a1ac..ee80ab28 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/hook.rst b/docs/hook.rst index 09f274ea..84d61b70 100644 --- a/docs/hook.rst +++ b/docs/hook.rst @@ -16,7 +16,7 @@ A full .pre-commit-config.yaml example you can use in your repository: repos: - repo: https://github.com/python-openapi/openapi-spec-validator - rev: 0.8.0 # The version to use or 'master' for latest + rev: 0.8.1 # The version to use or 'master' for latest hooks: - id: openapi-spec-validator diff --git a/docs/index.rst b/docs/index.rst index 4ee615a6..889f4ecd 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 @@ -77,7 +81,7 @@ Usage repos: - repo: https://github.com/python-openapi/openapi-spec-validator - rev: 0.8.0 # The version to use or 'master' for latest + rev: 0.8.1 # The version to use or 'master' for latest hooks: - id: openapi-spec-validator diff --git a/openapi_spec_validator/__init__.py b/openapi_spec_validator/__init__.py index b916b948..6a0b1dc7 100644 --- a/openapi_spec_validator/__init__.py +++ b/openapi_spec_validator/__init__.py @@ -15,7 +15,7 @@ __author__ = "Artur Maciag" __email__ = "maciag.artur@gmail.com" -__version__ = "0.8.0" +__version__ = "0.8.1" __url__ = "https://github.com/python-openapi/openapi-spec-validator" __license__ = "Apache License, Version 2.0" diff --git a/openapi_spec_validator/__main__.py b/openapi_spec_validator/__main__.py index 7ecc44cd..9b7bdeba 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 34506168..d30e9910 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/openapi_spec_validator/validation/keywords.py b/openapi_spec_validator/validation/keywords.py index 6c4b751f..df52f342 100644 --- a/openapi_spec_validator/validation/keywords.py +++ b/openapi_spec_validator/validation/keywords.py @@ -392,13 +392,20 @@ def __call__( if path_parameters is not None: names += list(self._get_path_param_names(path_parameters)) - all_params = list(set(names)) + all_params = set(names) + url_params = set(self._get_path_params_from_url(url)) - for path in self._get_path_params_from_url(url): + for path in sorted(url_params): if path not in all_params: yield UnresolvableParameterError( f"Path parameter '{path}' for '{name}' operation in '{url}' was not resolved" ) + + for path in sorted(all_params): + if path not in url_params: + yield UnresolvableParameterError( + f"Path parameter '{path}' for '{name}' operation in '{url}' was not resolved" + ) return def _get_path_param_names(self, params: SchemaPath) -> Iterator[str]: diff --git a/pyproject.toml b/pyproject.toml index 66f96329..e8759548 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "openapi-spec-validator" -version = "0.8.0" +version = "0.8.1" description = "OpenAPI 2.0 (aka Swagger) and OpenAPI 3 spec validator" authors = [ { name = "Artur Maciag", email = "maciag.artur@gmail.com" }, @@ -118,7 +118,7 @@ message_template = "Version {new_version}" tag_template = "{new_version}" [tool.tbump.version] -current = "0.8.0" +current = "0.8.1" regex = ''' (?P\d+) \. diff --git a/tests/integration/test_main.py b/tests/integration/test_main.py index 9125cdd8..1ce4e419 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"] diff --git a/tests/integration/validation/test_exceptions.py b/tests/integration/validation/test_exceptions.py index 2d2aba1d..5cfdd2e8 100644 --- a/tests/integration/validation/test_exceptions.py +++ b/tests/integration/validation/test_exceptions.py @@ -268,6 +268,46 @@ def test_undocumented_parameter(self): "'/test/{param1}/{param2}' was not resolved" ) + def test_extra_path_parameter_not_present_in_path(self): + spec = { + "openapi": "3.0.0", + "info": { + "title": "Test Api", + "version": "0.0.1", + }, + "paths": { + "/test": { + "get": { + "responses": { + "default": { + "description": "default response", + }, + }, + "parameters": [ + { + "name": "param1", + "in": "path", + "required": True, + "schema": { + "type": "integer", + }, + }, + ], + }, + }, + }, + } + + errors = OpenAPIV30SpecValidator(spec).iter_errors() + + errors_list = list(errors) + assert len(errors_list) == 1 + assert errors_list[0].__class__ == UnresolvableParameterError + assert errors_list[0].message == ( + "Path parameter 'param1' for 'get' operation in '/test' " + "was not resolved" + ) + def test_default_value_wrong_type(self): spec = { "openapi": "3.0.0",