From 8a6efce21096ce5eb94035c7e91248f7ab1caf24 Mon Sep 17 00:00:00 2001 From: Saquib Saifee Date: Sat, 28 Feb 2026 15:46:15 -0500 Subject: [PATCH] feat(validation): provide useful structured validation errors --- cyclonedx/validation/__init__.py | 15 ++++++++++++--- cyclonedx/validation/json.py | 17 +++++++++++++++-- cyclonedx/validation/xml.py | 3 +-- tests/test_validation_json.py | 26 ++++++++++++++++++++++++++ tests/test_validation_xml.py | 14 ++++++++++++++ 5 files changed, 68 insertions(+), 7 deletions(-) diff --git a/cyclonedx/validation/__init__.py b/cyclonedx/validation/__init__.py index 7ff3b8823..a3701ab2c 100644 --- a/cyclonedx/validation/__init__.py +++ b/cyclonedx/validation/__init__.py @@ -37,14 +37,23 @@ class ValidationError: data: Any """Raw error data from one of the underlying validation methods.""" - def __init__(self, data: Any) -> None: + message: str + """Human-readable error message suitable for end-user presentation.""" + + path: tuple[Union[str, int], ...] + """Path to the offending value if known.""" + + def __init__(self, data: Any, *, message: Optional[str] = None, + path: Iterable[Union[str, int]] = ()) -> None: self.data = data + self.message = str(data) if message is None else message + self.path = tuple(path) def __repr__(self) -> str: - return repr(self.data) + return f'{self.__class__.__name__}(message={self.message!r}, path={self.path!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 6431df57a..d5f197cf8 100644 --- a/cyclonedx/validation/json.py +++ b/cyclonedx/validation/json.py @@ -56,11 +56,24 @@ class JsonValidationError(ValidationError): + @classmethod + def __get_most_relevant_jsve(cls, e: 'JsonSchemaValidationError') -> 'JsonSchemaValidationError': + if not e.context: + return e + # nested `context` errors generally provide more useful details than + # generic parent messages, e.g. for oneOf/anyOf checks. + child = max(e.context, key=lambda ce: len(ce.absolute_path)) + return cls.__get_most_relevant_jsve(child) + @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.""" - # in preparation for https://github.com/CycloneDX/cyclonedx-python-lib/pull/836 - return cls(e) + useful = cls.__get_most_relevant_jsve(e) + return cls( + e, + message=useful.message, + path=tuple(useful.absolute_path) + ) class _BaseJsonValidator(BaseSchemabasedValidator, ABC): diff --git a/cyclonedx/validation/xml.py b/cyclonedx/validation/xml.py index 14f528088..1ac12981b 100644 --- a/cyclonedx/validation/xml.py +++ b/cyclonedx/validation/xml.py @@ -51,8 +51,7 @@ 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.""" - # in preparation for https://github.com/CycloneDX/cyclonedx-python-lib/pull/836 - return cls(e) + return cls(e, message=e.message, path=(e.path,) if e.path else ()) class _BaseXmlValidator(BaseSchemabasedValidator, ABC): diff --git a/tests/test_validation_json.py b/tests/test_validation_json.py index 48cfa1b68..181ee584b 100644 --- a/tests/test_validation_json.py +++ b/tests/test_validation_json.py @@ -113,6 +113,32 @@ def test_validate_expected_error_iterator(self, schema_version: SchemaVersion, t self.assertIsNotNone(validation_error.data) + + def test_validation_error_has_useful_message_and_path(self) -> None: + validator = JsonValidator(SchemaVersion.V1_6) + test_data = '{"bomFormat": "CycloneDX", "specVersion": "1.6", "version": 1, "metadata": {"timestamp": true}}' + try: + validation_error = validator.validate_str(test_data) + except MissingOptionalDependencyException: + self.skipTest('MissingOptionalDependencyException') + self.assertIsNotNone(validation_error) + assert validation_error is not None + self.assertTrue(validation_error.message) + self.assertEqual(('metadata', 'timestamp'), validation_error.path) + self.assertNotEqual(str(validation_error.data), str(validation_error)) + + def test_validation_error_prefers_nested_context_message(self) -> None: + validator = JsonValidator(SchemaVersion.V1_6) + test_data = '{"bomFormat": "CycloneDX", "specVersion": "1.6", "version": 1, "components": [{"type": "library", "name": "demo", "version": 1}]}' + try: + validation_error = validator.validate_str(test_data) + except MissingOptionalDependencyException: + self.skipTest('MissingOptionalDependencyException') + self.assertIsNotNone(validation_error) + assert validation_error is not None + self.assertEqual(('components', 0, 'version'), validation_error.path) + self.assertIn('is not of type', validation_error.message) + @ddt class TestJsonStrictValidator(TestCase): diff --git a/tests/test_validation_xml.py b/tests/test_validation_xml.py index 2565a85ad..3f71f1827 100644 --- a/tests/test_validation_xml.py +++ b/tests/test_validation_xml.py @@ -111,3 +111,17 @@ def test_validate_expected_error_iterator(self, schema_version: SchemaVersion, t self.assertGreater(len(validation_errors), 0) for validation_error in validation_errors: self.assertIsNotNone(validation_error.data) + + def test_validation_error_has_useful_message_and_path(self) -> None: + validator = XmlValidator(SchemaVersion.V1_6) + test_data = 'not-a-date' + try: + validation_error = validator.validate_str(test_data) + except MissingOptionalDependencyException: + self.skipTest('MissingOptionalDependencyException') + self.assertIsNotNone(validation_error) + assert validation_error is not None + self.assertTrue(validation_error.message) + self.assertTrue(validation_error.path) + self.assertNotEqual(str(validation_error.data), str(validation_error)) +