From a40d7f1acaa1ef5c60ef81f167f2ac543207e1de Mon Sep 17 00:00:00 2001 From: Jan Kowalleck Date: Tue, 1 Jul 2025 13:22:34 +0200 Subject: [PATCH 1/2] feat!: useful validation errors Signed-off-by: Jan Kowalleck --- cyclonedx/validation/__init__.py | 20 +++++++++----------- cyclonedx/validation/json.py | 24 ++++++++++++++++-------- cyclonedx/validation/xml.py | 26 ++++++++++++++++++-------- examples/complex_deserialize.py | 12 ++++++------ examples/complex_serialize.py | 12 ++++++------ 5 files changed, 55 insertions(+), 39 deletions(-) diff --git a/cyclonedx/validation/__init__.py b/cyclonedx/validation/__init__.py index 7ff3b882..a7dd2d81 100644 --- a/cyclonedx/validation/__init__.py +++ b/cyclonedx/validation/__init__.py @@ -18,7 +18,7 @@ from abc import ABC, abstractmethod from collections.abc import Iterable -from typing import TYPE_CHECKING, Any, Literal, Optional, Protocol, Union, overload +from typing import TYPE_CHECKING, Literal, Optional, Protocol, Union, overload from ..schema import OutputFormat @@ -29,22 +29,20 @@ class ValidationError: - """Validation failed with this specific error. + """Validation failed with this specific error. """ - Use :attr:`~data` to access the content. - """ - - data: Any - """Raw error data from one of the underlying validation methods.""" + def __init__(self, message: str) -> None: + self._message = message - def __init__(self, data: Any) -> None: - self.data = data + @property + def message(self) -> str: + return self._message def __repr__(self) -> str: - return repr(self.data) + return f'<{self.__class__.__qualname__} {self._message!r}>' def __str__(self) -> str: - return str(self.data) + return self._message class SchemabasedValidator(Protocol): diff --git a/cyclonedx/validation/json.py b/cyclonedx/validation/json.py index dbd3679b..70929dae 100644 --- a/cyclonedx/validation/json.py +++ b/cyclonedx/validation/json.py @@ -16,7 +16,7 @@ # Copyright (c) OWASP Foundation. All Rights Reserved. -__all__ = ['JsonValidator', 'JsonStrictValidator'] +__all__ = ['JsonValidator', 'JsonStrictValidator', 'JsonValidationError'] from abc import ABC from collections.abc import Iterable @@ -40,6 +40,7 @@ from referencing.jsonschema import DRAFT7 if TYPE_CHECKING: # pragma: no cover + from jsonschema.exceptions import ValidationError as JsonSchemaValidationError # type:ignore[import-untyped] from jsonschema.protocols import Validator as JsonSchemaValidator # type:ignore[import-untyped] except ImportError as err: _missing_deps_error = MissingOptionalDependencyException( @@ -48,6 +49,13 @@ ), err +class JsonValidationError(ValidationError): + @classmethod + def _make_from_jsve(cls, e: 'JsonSchemaValidationError') -> 'JsonValidationError': + """⚠️ This is an internal API. It is not part of the public interface and may change without notice.""" + return cls(e.message) # TODO: shorten and more useful message? + + class _BaseJsonValidator(BaseSchemabasedValidator, ABC): @property def output_format(self) -> Literal[OutputFormat.JSON]: @@ -60,16 +68,16 @@ def __init__(self, schema_version: 'SchemaVersion') -> None: # 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]: + def validate_str(self, data: str, *, all_errors: Literal[False] = ...) -> Optional[JsonValidationError]: ... # pragma: no cover @overload - def validate_str(self, data: str, *, all_errors: Literal[True]) -> Optional[Iterable[ValidationError]]: + def validate_str(self, data: str, *, all_errors: Literal[True]) -> Optional[Iterable[JsonValidationError]]: ... # pragma: no cover def validate_str( self, data: str, *, all_errors: bool = False - ) -> Union[None, ValidationError, Iterable[ValidationError]]: + ) -> Union[None, JsonValidationError, Iterable[JsonValidationError]]: ... # pragma: no cover # endregion @@ -79,22 +87,22 @@ def validate_str( 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]]: + ) -> Union[None, JsonValidationError, Iterable[JsonValidationError]]: raise self.__MDERROR[0] from self.__MDERROR[1] else: 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]]: + ) -> Union[None, JsonValidationError, Iterable[JsonValidationError]]: validator = self._validator # may throw on error that MUST NOT be caught 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)) \ + first_error = JsonValidationError._make_from_jsve(first_error) + return chain((first_error,), map(JsonValidationError._make_from_jsve, errors)) \ if all_errors \ else first_error diff --git a/cyclonedx/validation/xml.py b/cyclonedx/validation/xml.py index 7dca07c3..b0c1bac4 100644 --- a/cyclonedx/validation/xml.py +++ b/cyclonedx/validation/xml.py @@ -16,7 +16,7 @@ # Copyright (c) OWASP Foundation. All Rights Reserved. -__all__ = ['XmlValidator'] +__all__ = ['XmlValidator', 'XmlValidationError'] from abc import ABC from collections.abc import Iterable @@ -37,6 +37,9 @@ XMLSchema, fromstring as xml_fromstring, ) + + if TYPE_CHECKING: # pragma: no cover + from lxml.etree import _LogEntry as _XmlLogEntry except ImportError as err: _missing_deps_error = MissingOptionalDependencyException( 'This functionality requires optional dependencies.\n' @@ -44,6 +47,13 @@ ), err +class XmlValidationError(ValidationError): + @classmethod + def _make_from_xle(cls, e: '_XmlLogEntry') -> 'XmlValidationError': + """⚠️ This is an internal API. It is not part of the public interface and may change without notice.""" + return cls(e.message) # TODO: shorten and more useful message? + + class _BaseXmlValidator(BaseSchemabasedValidator, ABC): @property @@ -57,16 +67,16 @@ def __init__(self, schema_version: 'SchemaVersion') -> None: # 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]: + def validate_str(self, data: str, *, all_errors: Literal[False] = ...) -> Optional[XmlValidationError]: ... # pragma: no cover @overload - def validate_str(self, data: str, *, all_errors: Literal[True]) -> Optional[Iterable[ValidationError]]: + def validate_str(self, data: str, *, all_errors: Literal[True]) -> Optional[Iterable[XmlValidationError]]: ... # pragma: no cover def validate_str( self, data: str, *, all_errors: bool = False - ) -> Union[None, ValidationError, Iterable[ValidationError]]: + ) -> Union[None, XmlValidationError, Iterable[XmlValidationError]]: ... # pragma: no cover # endregion typing-relevant @@ -76,13 +86,13 @@ def validate_str( 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]]: + ) -> Union[None, XmlValidationError, Iterable[XmlValidationError]]: raise self.__MDERROR[0] from self.__MDERROR[1] else: 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]]: + ) -> Union[None, XmlValidationError, Iterable[XmlValidationError]]: 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 @@ -91,9 +101,9 @@ def validate_str( # type:ignore[no-redef] # noqa:F811 # typing-relevant headers if valid: return None errors = validator.error_log - return map(ValidationError, errors) \ + return map(XmlValidationError._make_from_xle, errors) \ if all_errors \ - else ValidationError(errors.last_error) + else XmlValidationError._make_from_xle(errors.last_error) __validator: Optional['XMLSchema'] = None diff --git a/examples/complex_deserialize.py b/examples/complex_deserialize.py index d139aa01..097a3cc9 100644 --- a/examples/complex_deserialize.py +++ b/examples/complex_deserialize.py @@ -147,9 +147,9 @@ }""" my_json_validator = JsonStrictValidator(SchemaVersion.V1_6) try: - validation_errors = my_json_validator.validate_str(json_data) - if validation_errors: - print('JSON invalid', 'ValidationError:', repr(validation_errors), sep='\n', file=sys.stderr) + json_validation_errors = my_json_validator.validate_str(json_data) + if json_validation_errors: + print('JSON invalid', 'ValidationError:', repr(json_validation_errors), sep='\n', file=sys.stderr) sys.exit(2) print('JSON valid') except MissingOptionalDependencyException as error: @@ -248,9 +248,9 @@ """ my_xml_validator: 'XmlValidator' = make_schemabased_validator(OutputFormat.XML, SchemaVersion.V1_6) try: - validation_errors = my_xml_validator.validate_str(xml_data) - if validation_errors: - print('XML invalid', 'ValidationError:', repr(validation_errors), sep='\n', file=sys.stderr) + xml_validation_errors = my_xml_validator.validate_str(xml_data) + if xml_validation_errors: + print('XML invalid', 'ValidationError:', repr(xml_validation_errors), sep='\n', file=sys.stderr) sys.exit(2) print('XML valid') except MissingOptionalDependencyException as error: diff --git a/examples/complex_serialize.py b/examples/complex_serialize.py index e69d186d..771dbc47 100644 --- a/examples/complex_serialize.py +++ b/examples/complex_serialize.py @@ -91,9 +91,9 @@ print(serialized_json) my_json_validator = JsonStrictValidator(SchemaVersion.V1_6) try: - validation_errors = my_json_validator.validate_str(serialized_json) - if validation_errors: - print('JSON invalid', 'ValidationError:', repr(validation_errors), sep='\n', file=sys.stderr) + json_validation_errors = my_json_validator.validate_str(serialized_json) + if json_validation_errors: + print('JSON invalid', 'ValidationError:', repr(json_validation_errors), sep='\n', file=sys.stderr) sys.exit(2) print('JSON valid') except MissingOptionalDependencyException as error: @@ -112,9 +112,9 @@ my_xml_validator: 'XmlValidator' = make_schemabased_validator( my_xml_outputter.output_format, my_xml_outputter.schema_version) try: - validation_errors = my_xml_validator.validate_str(serialized_xml) - if validation_errors: - print('XML invalid', 'ValidationError:', repr(validation_errors), sep='\n', file=sys.stderr) + xml_validation_errors = my_xml_validator.validate_str(serialized_xml) + if xml_validation_errors: + print('XML invalid', 'ValidationError:', repr(xml_validation_errors), sep='\n', file=sys.stderr) sys.exit(2) print('XML valid') except MissingOptionalDependencyException as error: From cfbdd3099265e5512ded804bbe48d3fc043fbcde Mon Sep 17 00:00:00 2001 From: Jan Kowalleck Date: Tue, 1 Jul 2025 23:42:02 +0200 Subject: [PATCH 2/2] wip Signed-off-by: Jan Kowalleck --- cyclonedx/validation/json.py | 2 +- cyclonedx/validation/xml.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/cyclonedx/validation/json.py b/cyclonedx/validation/json.py index 70929dae..f228fb64 100644 --- a/cyclonedx/validation/json.py +++ b/cyclonedx/validation/json.py @@ -53,7 +53,7 @@ class JsonValidationError(ValidationError): @classmethod def _make_from_jsve(cls, e: 'JsonSchemaValidationError') -> 'JsonValidationError': """⚠️ This is an internal API. It is not part of the public interface and may change without notice.""" - return cls(e.message) # TODO: shorten and more useful message? + return cls(e.message) # TODO: shorten and more useful message? maybe there is a massage formatter? class _BaseJsonValidator(BaseSchemabasedValidator, ABC): diff --git a/cyclonedx/validation/xml.py b/cyclonedx/validation/xml.py index b0c1bac4..b685a950 100644 --- a/cyclonedx/validation/xml.py +++ b/cyclonedx/validation/xml.py @@ -49,9 +49,9 @@ class XmlValidationError(ValidationError): @classmethod - def _make_from_xle(cls, e: '_XmlLogEntry') -> 'XmlValidationError': + def __make_from_xle(cls, e: '_XmlLogEntry') -> 'XmlValidationError': """⚠️ This is an internal API. It is not part of the public interface and may change without notice.""" - return cls(e.message) # TODO: shorten and more useful message? + return cls(e.message) # TODO: shorten and more useful message? maybe there is a massage formatter? class _BaseXmlValidator(BaseSchemabasedValidator, ABC):