diff --git a/cyclonedx/validation/__init__.py b/cyclonedx/validation/__init__.py index 4f5c775f..7ff3b882 100644 --- a/cyclonedx/validation/__init__.py +++ b/cyclonedx/validation/__init__.py @@ -17,6 +17,7 @@ from abc import ABC, abstractmethod +from collections.abc import Iterable from typing import TYPE_CHECKING, Any, Literal, Optional, Protocol, Union, overload from ..schema import OutputFormat @@ -34,6 +35,7 @@ class ValidationError: """ data: Any + """Raw error data from one of the underlying validation methods.""" def __init__(self, data: Any) -> None: self.data = data @@ -48,16 +50,45 @@ 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 (any)one - if any :return: validation error :retval None: if ``data`` is valid :retval ValidationError: if ``data`` is invalid """ ... # pragma: no cover + @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 (any)one - 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 + :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`` + :retval Iterable[ValidationError]: if ``data`` is invalid and ``all_errors`` is ``True`` + """ + ... # pragma: no cover + class BaseSchemabasedValidator(ABC, SchemabasedValidator): """Base Schema-based Validator""" diff --git a/cyclonedx/validation/json.py b/cyclonedx/validation/json.py index 7e6dcf0b..dbd3679b 100644 --- a/cyclonedx/validation/json.py +++ b/cyclonedx/validation/json.py @@ -19,8 +19,10 @@ __all__ = ['JsonValidator', 'JsonStrictValidator'] 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 @@ -33,7 +35,6 @@ _missing_deps_error: Optional[tuple[MissingOptionalDependencyException, ImportError]] = None try: - from jsonschema.exceptions import ValidationError as JsonValidationError # type:ignore[import-untyped] from jsonschema.validators import Draft7Validator # type:ignore[import-untyped] from referencing import Registry from referencing.jsonschema import DRAFT7 @@ -56,24 +57,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] else: - def validate_str(self, data: str) -> Optional[ValidationError]: - return self._validata_data( - json_loads(data)) - def _validata_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 JsonValidationError as error: - return ValidationError(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 = ValidationError(first_error) + return chain((first_error,), map(ValidationError, errors)) \ + if all_errors \ + else first_error __validator: Optional['JsonSchemaValidator'] = None diff --git a/cyclonedx/validation/xml.py b/cyclonedx/validation/xml.py index 6df74244..7dca07c3 100644 --- a/cyclonedx/validation/xml.py +++ b/cyclonedx/validation/xml.py @@ -19,7 +19,8 @@ __all__ = ['XmlValidator'] from abc import ABC -from typing import TYPE_CHECKING, Any, Literal, Optional +from collections.abc import Iterable +from typing import TYPE_CHECKING, Literal, Optional, Union, overload from ..exception import MissingOptionalDependencyException from ..schema import OutputFormat @@ -53,23 +54,46 @@ def __init__(self, schema_version: 'SchemaVersion') -> None: # this is the def that is used for generating the documentation super().__init__(schema_version) - if _missing_deps_error: + # 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] + else: - def validate_str(self, data: str) -> Optional[ValidationError]: - return self._validata_data( - xml_fromstring( # nosec B320 + 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 + valid = validator.validate( + xml_fromstring( # nosec B320 -- we use a custom prepared safe parser bytes(data, encoding='utf8'), parser=self.__xml_parser)) - - def _validata_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 diff --git a/tests/test_validation_json.py b/tests/test_validation_json.py index 9bc1a7cc..48cfa1b6 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): @@ -122,7 +141,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 = JsonStrictValidator(schema_version) with open(join(test_data_file)) as tdfh: test_data = tdfh.read() @@ -132,3 +151,22 @@ def test_validate_expected_error(self, schema_version: SchemaVersion, test_data_ self.skipTest('MissingOptionalDependencyException') 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) diff --git a/tests/test_validation_xml.py b/tests/test_validation_xml.py index 81a56cce..2565a85a 100644 --- a/tests/test_validation_xml.py +++ b/tests/test_validation_xml.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 = XmlValidator(schema_version) with open(join(test_data_file)) as tdfh: test_data = tdfh.read() @@ -92,3 +92,22 @@ def test_validate_expected_error(self, schema_version: SchemaVersion, test_data_ self.skipTest('MissingOptionalDependencyException') 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 = 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)