From 98bc301967d2ea58b79ad1d2511fd550b61f3b62 Mon Sep 17 00:00:00 2001 From: Jan Kowalleck Date: Thu, 26 Jun 2025 12:08:55 +0200 Subject: [PATCH 1/2] feat: SchemabasedValidator.validate_str can return an iterator over all errors Signed-off-by: Jan Kowalleck --- cyclonedx/validation/__init__.py | 29 ++++++++++++++--- cyclonedx/validation/json.py | 52 ++++++++++++++++++++----------- cyclonedx/validation/xml.py | 53 +++++++++++++++++++------------- 3 files changed, 91 insertions(+), 43 deletions(-) diff --git a/cyclonedx/validation/__init__.py b/cyclonedx/validation/__init__.py index 8163e1ef..a3bf9996 100644 --- a/cyclonedx/validation/__init__.py +++ b/cyclonedx/validation/__init__.py @@ -111,21 +111,42 @@ def __str__(self) -> str: class SchemabasedValidator(Protocol): """Schema-based Validator protocol""" - def validate_str(self, data: str) -> Optional[ValidationError]: + @overload + def validate_str(self, data: str, *, all_errors: Literal[False] = ...) -> Optional[ValidationError]: """Validate a string :param data: the data string to validate + :param all_errors: whether to return all errors or only the last error - if any :return: validation error :retval None: if ``data`` is valid :retval ValidationError: if ``data`` is invalid """ ... # pragma: no cover - def iterate_errors(self, data: str) -> Iterable[ValidationError]: - """Validate a string, enumerating all the problems. + @overload + def validate_str(self, data: str, *, all_errors: Literal[True]) -> Optional[Iterable[ValidationError]]: + """Validate a string + + :param data: the data string to validate + :param all_errors: whether to return all errors or only the last error - if any + :return: validation error + :retval None: if ``data`` is valid + :retval Iterable[ValidationError]: if ``data`` is invalid + """ + ... # pragma: no cover + + def validate_str( + self, data: str, *, + all_errors: bool = False + ) -> Union[None, ValidationError, Iterable[ValidationError]]: + """Validate a string :param data: the data string to validate - :return: iterator over the errors + :param all_errors: whether to return all errors or only the last error - if any + :return: validation error + :retval None: if ``data`` is valid + :retval ValidationError: if ``data`` is invalid and ``all_errors`` is ``False`` + :retval Iterable[ValidationError]: if ``data`` is invalid and ``all_errors`` is ``True`` """ ... # pragma: no cover diff --git a/cyclonedx/validation/json.py b/cyclonedx/validation/json.py index c3ddfa27..747c8627 100644 --- a/cyclonedx/validation/json.py +++ b/cyclonedx/validation/json.py @@ -20,8 +20,9 @@ from abc import ABC from collections.abc import Iterable +from itertools import chain from json import loads as json_loads -from typing import TYPE_CHECKING, Any, Literal, Optional +from typing import TYPE_CHECKING, Any, Literal, Optional, Union, overload from ..schema import OutputFormat @@ -102,31 +103,46 @@ def __init__(self, schema_version: 'SchemaVersion') -> None: # this is the def that is used for generating the documentation super().__init__(schema_version) + # region typing-relevant copy from parent class - needed for mypy and doc tools + + @overload + def validate_str(self, data: str, *, all_errors: Literal[False] = ...) -> Optional[ValidationError]: + ... # pragma: no cover + + @overload + def validate_str(self, data: str, *, all_errors: Literal[True]) -> Optional[Iterable[ValidationError]]: + ... # pragma: no cover + + def validate_str( + self, data: str, *, all_errors: bool = False + ) -> Union[None, ValidationError, Iterable[ValidationError]]: + ... # pragma: no cover + + # endregion + if _missing_deps_error: # noqa:C901 __MDERROR = _missing_deps_error - def validate_str(self, data: str) -> Optional[ValidationError]: + def validate_str( # type:ignore[no-redef] # noqa:F811 # typing-relevant headers go first + self, data: str, *, all_errors: bool = False + ) -> Union[None, ValidationError, Iterable[ValidationError]]: raise self.__MDERROR[0] from self.__MDERROR[1] - def iterate_errors(self, data: str) -> Iterable[ValidationError]: - raise self.__MDERROR[0] from self.__MDERROR[1] else: - def iterate_errors(self, data: str) -> Iterable[ValidationError]: - json_data = json_loads(data) - validator = self._validator # may throw on error that MUST NOT be caught - yield from validator.iter_errors(json_data) - - def validate_str(self, data: str) -> Optional[ValidationError]: - return self._validate_data( - json_loads(data)) - def _validate_data(self, data: Any) -> Optional[ValidationError]: + def validate_str( # type:ignore[no-redef] # noqa:F811 # typing-relevant headers go first + self, data: str, *, all_errors: bool = False + ) -> Union[None, ValidationError, Iterable[ValidationError]]: validator = self._validator # may throw on error that MUST NOT be caught - try: - validator.validate(data) - except JsonSchemaValidationError as error: - return _JsonValidationError(error) - return None + structure = json_loads(data) + errors = validator.iter_errors(structure) + first_error = next(errors, None) + if first_error is None: + return None + first_error = _JsonValidationError(first_error) + return chain((first_error,), map(_JsonValidationError, errors)) \ + if all_errors \ + else first_error __validator: Optional['JsonSchemaValidator'] = None diff --git a/cyclonedx/validation/xml.py b/cyclonedx/validation/xml.py index e643aac0..7dca07c3 100644 --- a/cyclonedx/validation/xml.py +++ b/cyclonedx/validation/xml.py @@ -20,7 +20,7 @@ from abc import ABC from collections.abc import Iterable -from typing import TYPE_CHECKING, Any, Literal, Optional +from typing import TYPE_CHECKING, Literal, Optional, Union, overload from ..exception import MissingOptionalDependencyException from ..schema import OutputFormat @@ -54,35 +54,46 @@ def __init__(self, schema_version: 'SchemaVersion') -> None: # this is the def that is used for generating the documentation super().__init__(schema_version) + # region typing-relevant copy from parent class - needed for mypy and doc tools + + @overload + def validate_str(self, data: str, *, all_errors: Literal[False] = ...) -> Optional[ValidationError]: + ... # pragma: no cover + + @overload + def validate_str(self, data: str, *, all_errors: Literal[True]) -> Optional[Iterable[ValidationError]]: + ... # pragma: no cover + + def validate_str( + self, data: str, *, all_errors: bool = False + ) -> Union[None, ValidationError, Iterable[ValidationError]]: + ... # pragma: no cover + + # endregion typing-relevant + if _missing_deps_error: # noqa:C901 __MDERROR = _missing_deps_error - def validate_str(self, data: str) -> Optional[ValidationError]: + def validate_str( # type:ignore[no-redef] # noqa:F811 # typing-relevant headers go first + self, data: str, *, all_errors: bool = False + ) -> Union[None, ValidationError, Iterable[ValidationError]]: raise self.__MDERROR[0] from self.__MDERROR[1] - def iterate_errors(self, data: str) -> Iterable[ValidationError]: - raise self.__MDERROR[0] from self.__MDERROR[1] else: - def iterate_errors(self, data: str) -> Iterable[ValidationError]: - xml_data = xml_fromstring( # nosec B320 - bytes(data, encoding='utf8'), - parser=self.__xml_parser) + def validate_str( # type:ignore[no-redef] # noqa:F811 # typing-relevant headers go first + self, data: str, *, all_errors: bool = False + ) -> Union[None, ValidationError, Iterable[ValidationError]]: validator = self._validator # may throw on error that MUST NOT be caught - validator.validate(xml_data) - for error in validator.error_log: - yield ValidationError(error) - - def validate_str(self, data: str) -> Optional[ValidationError]: - return self._validate_data( - xml_fromstring( # nosec B320 + valid = validator.validate( + xml_fromstring( # nosec B320 -- we use a custom prepared safe parser bytes(data, encoding='utf8'), parser=self.__xml_parser)) - - def _validate_data(self, data: Any) -> Optional[ValidationError]: - validator = self._validator # may throw on error that MUST NOT be caught - if not validator.validate(data): - return ValidationError(validator.error_log.last_error) - return None + if valid: + return None + errors = validator.error_log + return map(ValidationError, errors) \ + if all_errors \ + else ValidationError(errors.last_error) __validator: Optional['XMLSchema'] = None From 30028888afaf766443f09b0b1c3f8b4c7cc9dd09 Mon Sep 17 00:00:00 2001 From: Jan Kowalleck Date: Thu, 26 Jun 2025 12:25:45 +0200 Subject: [PATCH 2/2] tests Signed-off-by: Jan Kowalleck --- cyclonedx/validation/__init__.py | 6 ++--- tests/test_validation_json.py | 44 ++++++++++++++++++++++++++++---- tests/test_validation_xml.py | 23 ++++++++++++++--- 3 files changed, 61 insertions(+), 12 deletions(-) diff --git a/cyclonedx/validation/__init__.py b/cyclonedx/validation/__init__.py index a3bf9996..baabac03 100644 --- a/cyclonedx/validation/__init__.py +++ b/cyclonedx/validation/__init__.py @@ -116,7 +116,7 @@ def validate_str(self, data: str, *, all_errors: Literal[False] = ...) -> Option """Validate a string :param data: the data string to validate - :param all_errors: whether to return all errors or only the last error - if any + :param all_errors: whether to return all errors or only (any)one - if any :return: validation error :retval None: if ``data`` is valid :retval ValidationError: if ``data`` is invalid @@ -128,7 +128,7 @@ def validate_str(self, data: str, *, all_errors: Literal[True]) -> Optional[Iter """Validate a string :param data: the data string to validate - :param all_errors: whether to return all errors or only the last error - if any + :param all_errors: whether to return all errors or only (any)one - if any :return: validation error :retval None: if ``data`` is valid :retval Iterable[ValidationError]: if ``data`` is invalid @@ -142,7 +142,7 @@ def validate_str( """Validate a string :param data: the data string to validate - :param all_errors: whether to return all errors or only the last error - if any + :param all_errors: whether to return all errors or only (any)one - if any :return: validation error :retval None: if ``data`` is valid :retval ValidationError: if ``data`` is invalid and ``all_errors`` is ``False`` diff --git a/tests/test_validation_json.py b/tests/test_validation_json.py index 1cebb5b7..3313d24b 100644 --- a/tests/test_validation_json.py +++ b/tests/test_validation_json.py @@ -82,7 +82,7 @@ def test_validate_no_none(self, schema_version: SchemaVersion, test_data_file: s _dp_sv_own(False) )) @unpack - def test_validate_expected_error(self, schema_version: SchemaVersion, test_data_file: str) -> None: + def test_validate_expected_error_one(self, schema_version: SchemaVersion, test_data_file: str) -> None: validator = JsonValidator(schema_version) with open(join(test_data_file)) as tdfh: test_data = tdfh.read() @@ -93,6 +93,25 @@ def test_validate_expected_error(self, schema_version: SchemaVersion, test_data_ self.assertIsNotNone(validation_error) self.assertIsNotNone(validation_error.data) + @idata(chain( + _dp_sv_tf(False), + _dp_sv_own(False) + )) + @unpack + def test_validate_expected_error_iterator(self, schema_version: SchemaVersion, test_data_file: str) -> None: + validator = JsonValidator(schema_version) + with open(join(test_data_file)) as tdfh: + test_data = tdfh.read() + try: + validation_errors = validator.validate_str(test_data, all_errors=True) + except MissingOptionalDependencyException: + self.skipTest('MissingOptionalDependencyException') + self.assertIsNotNone(validation_errors) + validation_errors = tuple(validation_errors) + self.assertGreater(len(validation_errors), 0) + for validation_error in validation_errors: + self.assertIsNotNone(validation_error.data) + @ddt class TestJsonStrictValidator(TestCase): @@ -117,14 +136,12 @@ def test_validate_no_none(self, schema_version: SchemaVersion, test_data_file: s self.skipTest('MissingOptionalDependencyException') self.assertIsNone(validation_error) - self.assertEqual(list(validator.iterate_errors(test_data)), []) - @idata(chain( _dp_sv_tf(False), _dp_sv_own(False) )) @unpack - def test_validate_expected_error(self, schema_version: SchemaVersion, test_data_file: str) -> None: + def test_validate_expected_error_one(self, schema_version: SchemaVersion, test_data_file: str) -> None: validator = JsonStrictValidator(schema_version) with open(join(test_data_file)) as tdfh: test_data = tdfh.read() @@ -141,4 +158,21 @@ def test_validate_expected_error(self, schema_version: SchemaVersion, test_data_ squeezed_message = validation_error.get_squeezed_message(max_size=100) self.assertLessEqual(len(squeezed_message), 100, squeezed_message) - self.assertNotEqual(list(validator.iterate_errors(test_data)), []) + @idata(chain( + _dp_sv_tf(False), + _dp_sv_own(False) + )) + @unpack + def test_validate_expected_error_iterator(self, schema_version: SchemaVersion, test_data_file: str) -> None: + validator = JsonValidator(schema_version) + with open(join(test_data_file)) as tdfh: + test_data = tdfh.read() + try: + validation_errors = validator.validate_str(test_data, all_errors=True) + except MissingOptionalDependencyException: + self.skipTest('MissingOptionalDependencyException') + self.assertIsNotNone(validation_errors) + validation_errors = tuple(validation_errors) + self.assertGreater(len(validation_errors), 0) + for validation_error in validation_errors: + self.assertIsNotNone(validation_error.data) diff --git a/tests/test_validation_xml.py b/tests/test_validation_xml.py index 4eae98d6..4e83784e 100644 --- a/tests/test_validation_xml.py +++ b/tests/test_validation_xml.py @@ -77,14 +77,12 @@ def test_validate_no_none(self, schema_version: SchemaVersion, test_data_file: s self.skipTest('MissingOptionalDependencyException') self.assertIsNone(validation_error) - self.assertEqual(list(validator.iterate_errors(test_data)), []) - @idata(chain( _dp_sv_tf(False), _dp_sv_own(False) )) @unpack - def test_validate_expected_error(self, schema_version: SchemaVersion, test_data_file: str) -> None: + def test_validate_expected_error_one(self, schema_version: SchemaVersion, test_data_file: str) -> None: validator = XmlValidator(schema_version) with open(join(test_data_file)) as tdfh: test_data = tdfh.read() @@ -101,4 +99,21 @@ def test_validate_expected_error(self, schema_version: SchemaVersion, test_data_ squeezed_message = validation_error.get_squeezed_message(max_size=100) self.assertLessEqual(len(squeezed_message), 100, squeezed_message) - self.assertNotEqual(list(validator.iterate_errors(test_data)), []) + @idata(chain( + _dp_sv_tf(False), + _dp_sv_own(False) + )) + @unpack + def test_validate_expected_error_iterator(self, schema_version: SchemaVersion, test_data_file: str) -> None: + validator = XmlValidator(schema_version) + with open(join(test_data_file)) as tdfh: + test_data = tdfh.read() + try: + validation_errors = validator.validate_str(test_data, all_errors=True) + except MissingOptionalDependencyException: + self.skipTest('MissingOptionalDependencyException') + self.assertIsNotNone(validation_errors) + validation_errors = tuple(validation_errors) + self.assertGreater(len(validation_errors), 0) + for validation_error in validation_errors: + self.assertIsNotNone(validation_error.data)