From c66c077d6be47bee0186869bf0247736bffb190a Mon Sep 17 00:00:00 2001 From: Saquib Saifee Date: Tue, 14 Jul 2026 16:16:37 -0400 Subject: [PATCH 1/2] feat!: useful structured validation errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the planned improvement from PR #836 by the maintainer. - ValidationError gains structured fields: message (str), path (tuple), and data (raw backend object) — replacing the bare data-only shape - JsonValidationError._make_from_jsve recurses into nested jsonschema context errors to surface the most specific leaf failure for oneOf/anyOf - XmlValidationError._make_from_xle maps lxml _LogEntry.message and path - __repr__ and __str__ now emit the human-readable message field Closes #827 Signed-off-by: Saquib Saifee --- cyclonedx/validation/__init__.py | 20 +++++++++++++------- cyclonedx/validation/json.py | 17 +++++++++++++++-- cyclonedx/validation/xml.py | 3 +-- 3 files changed, 29 insertions(+), 11 deletions(-) diff --git a/cyclonedx/validation/__init__.py b/cyclonedx/validation/__init__.py index 7ff3b8823..fad11f783 100644 --- a/cyclonedx/validation/__init__.py +++ b/cyclonedx/validation/__init__.py @@ -29,22 +29,28 @@ class ValidationError: - """Validation failed with this specific error. - - Use :attr:`~data` to access the content. - """ + """Validation failed with this specific error.""" 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 in the document, 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..b24d6f108 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 + # the generic parent message (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): From ac93dbcab96dea4835a878a06553ebf9f120822f Mon Sep 17 00:00:00 2001 From: Saquib Saifee Date: Tue, 14 Jul 2026 16:54:00 -0400 Subject: [PATCH 2/2] feat!: useful structured validation errors PRs already in main that this builds on: - #834 (merged): added all_errors support and JsonValidationError / XmlValidationError subclass stubs - #840 (merged): refined the subclass stubs further - #836 (WIP by maintainer): placeholder for this exact work Changes ------- ValidationError (__init__.py) - Add message (str): human-readable, bounded error message - Add path (tuple[str|int, ...]): location of the offending value - Keep data (Any): raw backend object, unchanged for backward compat - __str__ now returns message; __repr__ is structured JsonValidationError (json.py) - __get_most_relevant_jsve: recurse into nested jsonschema context errors to surface the deepest leaf failure for oneOf/anyOf checks, instead of emitting the generic parent message - _shorten_message: (1) replace a bloated repr(instance) with a head...tail summary for uniqueItems/large-document failures, then (2) hard-cap the whole message at 256 chars + ellipsis XmlValidationError (xml.py) - _shorten_xml_message: hard-cap lxml _LogEntry.message at 256 chars, preventing the full SPDX enumeration set from appearing verbatim - path populated from _LogEntry.path (XPath location string) Before vs after (invalid-license-id test files from issue #827) JSON str(error): 111,672 chars -> 257 chars XML str(error): 13,430 chars -> 257 chars error.message: AttributeError -> bounded str error.path: AttributeError -> structured tuple Tests (test_validation_json.py, test_validation_xml.py) - Assert error is the correct subtype (JsonValidationError / XmlValidationError) - Assert .message is a str and .path is a tuple - Assert len(message) <= 257 across every invalid test file for every schema version Closes #827 Signed-off-by: Saquib Saifee --- cyclonedx/validation/json.py | 28 +++++++++++++++++++++++++++- cyclonedx/validation/xml.py | 18 +++++++++++++++++- tests/test_validation_json.py | 22 +++++++++++++++++++++- tests/test_validation_xml.py | 12 +++++++++++- 4 files changed, 76 insertions(+), 4 deletions(-) diff --git a/cyclonedx/validation/json.py b/cyclonedx/validation/json.py index b24d6f108..b7261b888 100644 --- a/cyclonedx/validation/json.py +++ b/cyclonedx/validation/json.py @@ -55,6 +55,32 @@ ), err +_MAX_MESSAGE_LEN = 256 # chars to keep from a message before truncating + + +def _shorten_message(message: str, instance: object) -> str: + """Return a human-readable, bounded error message. + + Two sources of bloat are addressed: + 1. jsonschema embeds ``repr(instance)`` verbatim — for large SBOM + documents (e.g. a ``uniqueItems`` failure on ``dependencies``) this + alone can be hundreds of kilobytes. + 2. ``enum`` keywords include the full allowed-values list in the message + (e.g. the ~800-entry SPDX licence ID list). + + Strategy: replace a long ``repr(instance)`` first, then hard-cap the + whole message and append ``…`` if it still exceeds ``_MAX_MESSAGE_LEN``. + """ + full_repr = repr(instance) + if len(full_repr) > _MAX_MESSAGE_LEN // 2: + half = _MAX_MESSAGE_LEN // 4 + short_repr = f'{full_repr[:half]}...{full_repr[-half:]}' + message = message.replace(full_repr, short_repr, 1) + if len(message) > _MAX_MESSAGE_LEN: + message = message[:_MAX_MESSAGE_LEN] + '…' + return message + + class JsonValidationError(ValidationError): @classmethod def __get_most_relevant_jsve(cls, e: 'JsonSchemaValidationError') -> 'JsonSchemaValidationError': @@ -71,7 +97,7 @@ def _make_from_jsve(cls, e: 'JsonSchemaValidationError') -> 'JsonValidationError useful = cls.__get_most_relevant_jsve(e) return cls( e, - message=useful.message, + message=_shorten_message(useful.message, useful.instance), path=tuple(useful.absolute_path) ) diff --git a/cyclonedx/validation/xml.py b/cyclonedx/validation/xml.py index 1ac12981b..3ef8b799a 100644 --- a/cyclonedx/validation/xml.py +++ b/cyclonedx/validation/xml.py @@ -47,11 +47,27 @@ ), err +_MAX_MESSAGE_LEN = 256 # chars to keep from a message before truncating + + +def _shorten_xml_message(message: str) -> str: + """Cap an lxml error message at ``_MAX_MESSAGE_LEN`` characters. + + lxml embeds the full enumeration set in ``[facet 'enumeration']`` messages + (e.g. the ~800-entry SPDX licence ID list), producing multi-kilobyte + single-line strings. We hard-cap and append ``…`` so callers can safely + log or display the message. + """ + if len(message) > _MAX_MESSAGE_LEN: + return message[:_MAX_MESSAGE_LEN] + '…' + return message + + 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=e.message, path=(e.path,) if e.path else ()) + return cls(e, message=_shorten_xml_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..fed5131a1 100644 --- a/tests/test_validation_json.py +++ b/tests/test_validation_json.py @@ -25,7 +25,7 @@ from cyclonedx.exception import MissingOptionalDependencyException from cyclonedx.schema import OutputFormat, SchemaVersion -from cyclonedx.validation.json import JsonStrictValidator, JsonValidator +from cyclonedx.validation.json import JsonStrictValidator, JsonValidationError, JsonValidator from tests import OWN_DATA_DIRECTORY, SCHEMA_TESTDATA_DIRECTORY, DpTuple UNSUPPORTED_SCHEMA_VERSIONS = {SchemaVersion.V1_0, SchemaVersion.V1_1, } @@ -92,6 +92,11 @@ def test_validate_expected_error_one(self, schema_version: SchemaVersion, test_d self.skipTest('MissingOptionalDependencyException') self.assertIsNotNone(validation_error) self.assertIsNotNone(validation_error.data) + self.assertIsInstance(validation_error, JsonValidationError) + self.assertIsInstance(validation_error.message, str) + self.assertIsInstance(validation_error.path, tuple) + self.assertLessEqual(len(validation_error.message), 257, + 'message must be bounded (≤256 chars + ellipsis)') @idata(chain( _dp_sv_tf(False), @@ -111,6 +116,11 @@ 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) + self.assertIsInstance(validation_error, JsonValidationError) + self.assertIsInstance(validation_error.message, str) + self.assertIsInstance(validation_error.path, tuple) + self.assertLessEqual(len(validation_error.message), 257, + 'message must be bounded (≤256 chars + ellipsis)') @ddt @@ -151,6 +161,11 @@ def test_validate_expected_error_one(self, schema_version: SchemaVersion, test_d self.skipTest('MissingOptionalDependencyException') self.assertIsNotNone(validation_error) self.assertIsNotNone(validation_error.data) + self.assertIsInstance(validation_error, JsonValidationError) + self.assertIsInstance(validation_error.message, str) + self.assertIsInstance(validation_error.path, tuple) + self.assertLessEqual(len(validation_error.message), 257, + 'message must be bounded (≤256 chars + ellipsis)') @idata(chain( _dp_sv_tf(False), @@ -170,3 +185,8 @@ 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) + self.assertIsInstance(validation_error, JsonValidationError) + self.assertIsInstance(validation_error.message, str) + self.assertIsInstance(validation_error.path, tuple) + self.assertLessEqual(len(validation_error.message), 257, + 'message must be bounded (≤256 chars + ellipsis)') diff --git a/tests/test_validation_xml.py b/tests/test_validation_xml.py index 2565a85ad..ff424be47 100644 --- a/tests/test_validation_xml.py +++ b/tests/test_validation_xml.py @@ -25,7 +25,7 @@ from cyclonedx.exception import MissingOptionalDependencyException from cyclonedx.schema import OutputFormat, SchemaVersion -from cyclonedx.validation.xml import XmlValidator +from cyclonedx.validation.xml import XmlValidationError, XmlValidator from tests import OWN_DATA_DIRECTORY, SCHEMA_TESTDATA_DIRECTORY, DpTuple UNSUPPORTED_SCHEMA_VERSIONS = set() @@ -92,6 +92,11 @@ def test_validate_expected_error_one(self, schema_version: SchemaVersion, test_d self.skipTest('MissingOptionalDependencyException') self.assertIsNotNone(validation_error) self.assertIsNotNone(validation_error.data) + self.assertIsInstance(validation_error, XmlValidationError) + self.assertIsInstance(validation_error.message, str) + self.assertIsInstance(validation_error.path, tuple) + self.assertLessEqual(len(validation_error.message), 257, + 'message must be bounded (≤256 chars + ellipsis)') @idata(chain( _dp_sv_tf(False), @@ -111,3 +116,8 @@ 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) + self.assertIsInstance(validation_error, XmlValidationError) + self.assertIsInstance(validation_error.message, str) + self.assertIsInstance(validation_error.path, tuple) + self.assertLessEqual(len(validation_error.message), 257, + 'message must be bounded (≤256 chars + ellipsis)')