Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 12 additions & 3 deletions cyclonedx/validation/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
17 changes: 15 additions & 2 deletions cyclonedx/validation/json.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
3 changes: 1 addition & 2 deletions cyclonedx/validation/xml.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
26 changes: 26 additions & 0 deletions tests/test_validation_json.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):

Expand Down
14 changes: 14 additions & 0 deletions tests/test_validation_xml.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = '<bom xmlns="http://cyclonedx.org/schema/bom/1.6" version="1"><metadata><timestamp>not-a-date</timestamp></metadata></bom>'
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))