Skip to content

Commit f95576f

Browse files
feat: schema based validation may return iterable of all errors (#834)
--------- Signed-off-by: Krisztian Fekete <[email protected]> Signed-off-by: Jan Kowalleck <[email protected]> Co-authored-by: Jan Kowalleck <[email protected]>
1 parent d79adc4 commit f95576f

File tree

5 files changed

+163
-28
lines changed

5 files changed

+163
-28
lines changed

cyclonedx/validation/__init__.py

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717

1818

1919
from abc import ABC, abstractmethod
20+
from collections.abc import Iterable
2021
from typing import TYPE_CHECKING, Any, Literal, Optional, Protocol, Union, overload
2122

2223
from ..schema import OutputFormat
@@ -34,6 +35,7 @@ class ValidationError:
3435
"""
3536

3637
data: Any
38+
"""Raw error data from one of the underlying validation methods."""
3739

3840
def __init__(self, data: Any) -> None:
3941
self.data = data
@@ -48,16 +50,45 @@ def __str__(self) -> str:
4850
class SchemabasedValidator(Protocol):
4951
"""Schema-based Validator protocol"""
5052

51-
def validate_str(self, data: str) -> Optional[ValidationError]:
53+
@overload
54+
def validate_str(self, data: str, *, all_errors: Literal[False] = ...) -> Optional[ValidationError]:
5255
"""Validate a string
5356
5457
:param data: the data string to validate
58+
:param all_errors: whether to return all errors or only (any)one - if any
5559
:return: validation error
5660
:retval None: if ``data`` is valid
5761
:retval ValidationError: if ``data`` is invalid
5862
"""
5963
... # pragma: no cover
6064

65+
@overload
66+
def validate_str(self, data: str, *, all_errors: Literal[True]) -> Optional[Iterable[ValidationError]]:
67+
"""Validate a string
68+
69+
:param data: the data string to validate
70+
:param all_errors: whether to return all errors or only (any)one - if any
71+
:return: validation error
72+
:retval None: if ``data`` is valid
73+
:retval Iterable[ValidationError]: if ``data`` is invalid
74+
"""
75+
... # pragma: no cover
76+
77+
def validate_str(
78+
self, data: str, *,
79+
all_errors: bool = False
80+
) -> Union[None, ValidationError, Iterable[ValidationError]]:
81+
"""Validate a string
82+
83+
:param data: the data string to validate
84+
:param all_errors: whether to return all errors or only (any)one - if any
85+
:return: validation error
86+
:retval None: if ``data`` is valid
87+
:retval ValidationError: if ``data`` is invalid and ``all_errors`` is ``False``
88+
:retval Iterable[ValidationError]: if ``data`` is invalid and ``all_errors`` is ``True``
89+
"""
90+
... # pragma: no cover
91+
6192

6293
class BaseSchemabasedValidator(ABC, SchemabasedValidator):
6394
"""Base Schema-based Validator"""

cyclonedx/validation/json.py

Lines changed: 35 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,10 @@
1919
__all__ = ['JsonValidator', 'JsonStrictValidator']
2020

2121
from abc import ABC
22+
from collections.abc import Iterable
23+
from itertools import chain
2224
from json import loads as json_loads
23-
from typing import TYPE_CHECKING, Any, Literal, Optional
25+
from typing import TYPE_CHECKING, Any, Literal, Optional, Union, overload
2426

2527
from ..schema import OutputFormat
2628

@@ -33,7 +35,6 @@
3335

3436
_missing_deps_error: Optional[tuple[MissingOptionalDependencyException, ImportError]] = None
3537
try:
36-
from jsonschema.exceptions import ValidationError as JsonValidationError # type:ignore[import-untyped]
3738
from jsonschema.validators import Draft7Validator # type:ignore[import-untyped]
3839
from referencing import Registry
3940
from referencing.jsonschema import DRAFT7
@@ -56,24 +57,46 @@ def __init__(self, schema_version: 'SchemaVersion') -> None:
5657
# this is the def that is used for generating the documentation
5758
super().__init__(schema_version)
5859

60+
# region typing-relevant copy from parent class - needed for mypy and doc tools
61+
62+
@overload
63+
def validate_str(self, data: str, *, all_errors: Literal[False] = ...) -> Optional[ValidationError]:
64+
... # pragma: no cover
65+
66+
@overload
67+
def validate_str(self, data: str, *, all_errors: Literal[True]) -> Optional[Iterable[ValidationError]]:
68+
... # pragma: no cover
69+
70+
def validate_str(
71+
self, data: str, *, all_errors: bool = False
72+
) -> Union[None, ValidationError, Iterable[ValidationError]]:
73+
... # pragma: no cover
74+
75+
# endregion
76+
5977
if _missing_deps_error: # noqa:C901
6078
__MDERROR = _missing_deps_error
6179

62-
def validate_str(self, data: str) -> Optional[ValidationError]:
80+
def validate_str( # type:ignore[no-redef] # noqa:F811 # typing-relevant headers go first
81+
self, data: str, *, all_errors: bool = False
82+
) -> Union[None, ValidationError, Iterable[ValidationError]]:
6383
raise self.__MDERROR[0] from self.__MDERROR[1]
6484

6585
else:
66-
def validate_str(self, data: str) -> Optional[ValidationError]:
67-
return self._validata_data(
68-
json_loads(data))
6986

70-
def _validata_data(self, data: Any) -> Optional[ValidationError]:
87+
def validate_str( # type:ignore[no-redef] # noqa:F811 # typing-relevant headers go first
88+
self, data: str, *, all_errors: bool = False
89+
) -> Union[None, ValidationError, Iterable[ValidationError]]:
7190
validator = self._validator # may throw on error that MUST NOT be caught
72-
try:
73-
validator.validate(data)
74-
except JsonValidationError as error:
75-
return ValidationError(error)
76-
return None
91+
structure = json_loads(data)
92+
errors = validator.iter_errors(structure)
93+
first_error = next(errors, None)
94+
if first_error is None:
95+
return None
96+
first_error = ValidationError(first_error)
97+
return chain((first_error,), map(ValidationError, errors)) \
98+
if all_errors \
99+
else first_error
77100

78101
__validator: Optional['JsonSchemaValidator'] = None
79102

cyclonedx/validation/xml.py

Lines changed: 36 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,8 @@
1919
__all__ = ['XmlValidator']
2020

2121
from abc import ABC
22-
from typing import TYPE_CHECKING, Any, Literal, Optional
22+
from collections.abc import Iterable
23+
from typing import TYPE_CHECKING, Literal, Optional, Union, overload
2324

2425
from ..exception import MissingOptionalDependencyException
2526
from ..schema import OutputFormat
@@ -53,23 +54,46 @@ def __init__(self, schema_version: 'SchemaVersion') -> None:
5354
# this is the def that is used for generating the documentation
5455
super().__init__(schema_version)
5556

56-
if _missing_deps_error:
57+
# region typing-relevant copy from parent class - needed for mypy and doc tools
58+
59+
@overload
60+
def validate_str(self, data: str, *, all_errors: Literal[False] = ...) -> Optional[ValidationError]:
61+
... # pragma: no cover
62+
63+
@overload
64+
def validate_str(self, data: str, *, all_errors: Literal[True]) -> Optional[Iterable[ValidationError]]:
65+
... # pragma: no cover
66+
67+
def validate_str(
68+
self, data: str, *, all_errors: bool = False
69+
) -> Union[None, ValidationError, Iterable[ValidationError]]:
70+
... # pragma: no cover
71+
72+
# endregion typing-relevant
73+
74+
if _missing_deps_error: # noqa:C901
5775
__MDERROR = _missing_deps_error
5876

59-
def validate_str(self, data: str) -> Optional[ValidationError]:
77+
def validate_str( # type:ignore[no-redef] # noqa:F811 # typing-relevant headers go first
78+
self, data: str, *, all_errors: bool = False
79+
) -> Union[None, ValidationError, Iterable[ValidationError]]:
6080
raise self.__MDERROR[0] from self.__MDERROR[1]
81+
6182
else:
62-
def validate_str(self, data: str) -> Optional[ValidationError]:
63-
return self._validata_data(
64-
xml_fromstring( # nosec B320
83+
def validate_str( # type:ignore[no-redef] # noqa:F811 # typing-relevant headers go first
84+
self, data: str, *, all_errors: bool = False
85+
) -> Union[None, ValidationError, Iterable[ValidationError]]:
86+
validator = self._validator # may throw on error that MUST NOT be caught
87+
valid = validator.validate(
88+
xml_fromstring( # nosec B320 -- we use a custom prepared safe parser
6589
bytes(data, encoding='utf8'),
6690
parser=self.__xml_parser))
67-
68-
def _validata_data(self, data: Any) -> Optional[ValidationError]:
69-
validator = self._validator # may throw on error that MUST NOT be caught
70-
if not validator.validate(data):
71-
return ValidationError(validator.error_log.last_error)
72-
return None
91+
if valid:
92+
return None
93+
errors = validator.error_log
94+
return map(ValidationError, errors) \
95+
if all_errors \
96+
else ValidationError(errors.last_error)
7397

7498
__validator: Optional['XMLSchema'] = None
7599

tests/test_validation_json.py

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ def test_validate_no_none(self, schema_version: SchemaVersion, test_data_file: s
8282
_dp_sv_own(False)
8383
))
8484
@unpack
85-
def test_validate_expected_error(self, schema_version: SchemaVersion, test_data_file: str) -> None:
85+
def test_validate_expected_error_one(self, schema_version: SchemaVersion, test_data_file: str) -> None:
8686
validator = JsonValidator(schema_version)
8787
with open(join(test_data_file)) as tdfh:
8888
test_data = tdfh.read()
@@ -93,6 +93,25 @@ def test_validate_expected_error(self, schema_version: SchemaVersion, test_data_
9393
self.assertIsNotNone(validation_error)
9494
self.assertIsNotNone(validation_error.data)
9595

96+
@idata(chain(
97+
_dp_sv_tf(False),
98+
_dp_sv_own(False)
99+
))
100+
@unpack
101+
def test_validate_expected_error_iterator(self, schema_version: SchemaVersion, test_data_file: str) -> None:
102+
validator = JsonValidator(schema_version)
103+
with open(join(test_data_file)) as tdfh:
104+
test_data = tdfh.read()
105+
try:
106+
validation_errors = validator.validate_str(test_data, all_errors=True)
107+
except MissingOptionalDependencyException:
108+
self.skipTest('MissingOptionalDependencyException')
109+
self.assertIsNotNone(validation_errors)
110+
validation_errors = tuple(validation_errors)
111+
self.assertGreater(len(validation_errors), 0)
112+
for validation_error in validation_errors:
113+
self.assertIsNotNone(validation_error.data)
114+
96115

97116
@ddt
98117
class TestJsonStrictValidator(TestCase):
@@ -122,7 +141,7 @@ def test_validate_no_none(self, schema_version: SchemaVersion, test_data_file: s
122141
_dp_sv_own(False)
123142
))
124143
@unpack
125-
def test_validate_expected_error(self, schema_version: SchemaVersion, test_data_file: str) -> None:
144+
def test_validate_expected_error_one(self, schema_version: SchemaVersion, test_data_file: str) -> None:
126145
validator = JsonStrictValidator(schema_version)
127146
with open(join(test_data_file)) as tdfh:
128147
test_data = tdfh.read()
@@ -132,3 +151,22 @@ def test_validate_expected_error(self, schema_version: SchemaVersion, test_data_
132151
self.skipTest('MissingOptionalDependencyException')
133152
self.assertIsNotNone(validation_error)
134153
self.assertIsNotNone(validation_error.data)
154+
155+
@idata(chain(
156+
_dp_sv_tf(False),
157+
_dp_sv_own(False)
158+
))
159+
@unpack
160+
def test_validate_expected_error_iterator(self, schema_version: SchemaVersion, test_data_file: str) -> None:
161+
validator = JsonValidator(schema_version)
162+
with open(join(test_data_file)) as tdfh:
163+
test_data = tdfh.read()
164+
try:
165+
validation_errors = validator.validate_str(test_data, all_errors=True)
166+
except MissingOptionalDependencyException:
167+
self.skipTest('MissingOptionalDependencyException')
168+
self.assertIsNotNone(validation_errors)
169+
validation_errors = tuple(validation_errors)
170+
self.assertGreater(len(validation_errors), 0)
171+
for validation_error in validation_errors:
172+
self.assertIsNotNone(validation_error.data)

tests/test_validation_xml.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ def test_validate_no_none(self, schema_version: SchemaVersion, test_data_file: s
8282
_dp_sv_own(False)
8383
))
8484
@unpack
85-
def test_validate_expected_error(self, schema_version: SchemaVersion, test_data_file: str) -> None:
85+
def test_validate_expected_error_one(self, schema_version: SchemaVersion, test_data_file: str) -> None:
8686
validator = XmlValidator(schema_version)
8787
with open(join(test_data_file)) as tdfh:
8888
test_data = tdfh.read()
@@ -92,3 +92,22 @@ def test_validate_expected_error(self, schema_version: SchemaVersion, test_data_
9292
self.skipTest('MissingOptionalDependencyException')
9393
self.assertIsNotNone(validation_error)
9494
self.assertIsNotNone(validation_error.data)
95+
96+
@idata(chain(
97+
_dp_sv_tf(False),
98+
_dp_sv_own(False)
99+
))
100+
@unpack
101+
def test_validate_expected_error_iterator(self, schema_version: SchemaVersion, test_data_file: str) -> None:
102+
validator = XmlValidator(schema_version)
103+
with open(join(test_data_file)) as tdfh:
104+
test_data = tdfh.read()
105+
try:
106+
validation_errors = validator.validate_str(test_data, all_errors=True)
107+
except MissingOptionalDependencyException:
108+
self.skipTest('MissingOptionalDependencyException')
109+
self.assertIsNotNone(validation_errors)
110+
validation_errors = tuple(validation_errors)
111+
self.assertGreater(len(validation_errors), 0)
112+
for validation_error in validation_errors:
113+
self.assertIsNotNone(validation_error.data)

0 commit comments

Comments
 (0)