Skip to content

Commit

Permalink
refactor: schema based validator (#468)
Browse files Browse the repository at this point in the history
- restructured validators, to enable possible non-schema-based validation. 
- optimized `validation.schema.get_instance()`
- optimized `output.get_instance()`

---------

Signed-off-by: Jan Kowalleck <[email protected]>
  • Loading branch information
jkowalleck authored Oct 13, 2023
1 parent a911106 commit 65e79cf
Show file tree
Hide file tree
Showing 10 changed files with 115 additions and 73 deletions.
26 changes: 13 additions & 13 deletions cyclonedx/output/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,7 @@

import os
from abc import ABC, abstractmethod
from importlib import import_module
from typing import TYPE_CHECKING, Any, Iterable, Literal, Optional, Type, Union, overload
from typing import TYPE_CHECKING, Any, Iterable, Literal, Mapping, Optional, Type, Union, overload

from ..schema import OutputFormat, SchemaVersion

Expand Down Expand Up @@ -130,15 +129,16 @@ def get_instance(bom: 'Bom', output_format: OutputFormat = OutputFormat.XML,
:return: BaseOutput
"""
# all exceptions are undocumented, as they are pure functional, and should be prevented by correct typing...
if not isinstance(output_format, OutputFormat):
raise TypeError(f"unexpected output_format: {output_format!r}")
if not isinstance(schema_version, SchemaVersion):
raise TypeError(f"unexpected schema_version: {schema_version!r}")
try:
module = import_module(f'.{output_format.name.lower()}', __package__)
except ImportError as error:
raise ValueError(f'Unknown output_format: {output_format.name}') from error
klass: Optional[Type[BaseOutput]] = module.BY_SCHEMA_VERSION.get(schema_version, None)
if TYPE_CHECKING: # pragma: no cover
BY_SCHEMA_VERSION: Mapping[SchemaVersion, Type[BaseOutput]]
if OutputFormat.JSON is output_format:
from .json import BY_SCHEMA_VERSION
elif OutputFormat.XML is output_format:
from .xml import BY_SCHEMA_VERSION
else:
raise ValueError(f"Unexpected output_format: {output_format!r}")

klass = BY_SCHEMA_VERSION.get(schema_version, None)
if klass is None:
raise ValueError(f'Unknown {output_format.name}/schema_version: {schema_version.name}')
return klass(bom=bom)
raise ValueError(f'Unknown {output_format.name}/schema_version: {schema_version!r}')
return klass(bom)
51 changes: 6 additions & 45 deletions cyclonedx/validation/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,15 +15,12 @@


from abc import ABC, abstractmethod
from importlib import import_module
from typing import TYPE_CHECKING, Any, Literal, Optional, Protocol, Type, Union, overload
from typing import TYPE_CHECKING, Any, Optional, Protocol

from ..schema import OutputFormat

if TYPE_CHECKING: # pragma: no cover
from ..schema import SchemaVersion
from .json import JsonValidator
from .xml import XmlValidator


class ValidationError:
Expand All @@ -44,8 +41,8 @@ def __str__(self) -> str:
return str(self.data)


class Validator(Protocol):
"""Validator protocol"""
class SchemaBasedValidator(Protocol):
"""Schema based Validator protocol"""

def validate_str(self, data: str) -> Optional[ValidationError]:
"""Validate a string
Expand All @@ -58,13 +55,13 @@ def validate_str(self, data: str) -> Optional[ValidationError]:
...


class BaseValidator(ABC, Validator):
"""BaseValidator"""
class BaseSchemaBasedValidator(ABC, SchemaBasedValidator):
"""Base Schema based Validator"""

def __init__(self, schema_version: 'SchemaVersion') -> None:
self.__schema_version = schema_version
if not self._schema_file:
raise ValueError(f'unsupported schema_version: {schema_version}')
raise ValueError(f'Unsupported schema_version: {schema_version!r}')

@property
def schema_version(self) -> 'SchemaVersion':
Expand All @@ -82,39 +79,3 @@ def output_format(self) -> OutputFormat:
def _schema_file(self) -> Optional[str]:
"""get the schema file according to schema version."""
...


@overload
def get_instance(output_format: Literal[OutputFormat.JSON], schema_version: 'SchemaVersion'
) -> 'JsonValidator':
...


@overload
def get_instance(output_format: Literal[OutputFormat.XML], schema_version: 'SchemaVersion'
) -> 'XmlValidator':
...


@overload
def get_instance(output_format: OutputFormat, schema_version: 'SchemaVersion'
) -> Union['JsonValidator', 'XmlValidator']:
...


def get_instance(output_format: OutputFormat, schema_version: 'SchemaVersion') -> BaseValidator:
"""get the default validator for a certain `OutputFormat`
Raises error when no instance could be built.
"""
# all exceptions are undocumented, as they are pure functional, and should be prevented by correct typing...
if not isinstance(output_format, OutputFormat):
raise TypeError(f"unexpected output_format: {output_format!r}")
try:
module = import_module(f'.{output_format.name.lower()}', __package__)
except ImportError as error:
raise ValueError(f'Unknown output_format: {output_format.name}') from error
klass: Optional[Type[BaseValidator]] = getattr(module, f'{output_format.name.capitalize()}Validator', None)
if klass is None:
raise ValueError(f'Missing Validator for {output_format.name}')
return klass(schema_version)
8 changes: 4 additions & 4 deletions cyclonedx/validation/json.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@

from ..exception import MissingOptionalDependencyException
from ..schema._res import BOM_JSON as _S_BOM, BOM_JSON_STRICT as _S_BOM_STRICT, JSF as _S_JSF, SPDX_JSON as _S_SPDX
from . import BaseValidator, ValidationError, Validator
from . import BaseSchemaBasedValidator, SchemaBasedValidator, ValidationError

_missing_deps_error: Optional[Tuple[MissingOptionalDependencyException, ImportError]] = None
try:
Expand All @@ -45,7 +45,7 @@
), err


class _BaseJsonValidator(BaseValidator, ABC):
class _BaseJsonValidator(BaseSchemaBasedValidator, ABC):
@property
def output_format(self) -> Literal[OutputFormat.JSON]:
return OutputFormat.JSON
Expand Down Expand Up @@ -98,15 +98,15 @@ def __make_validator_registry() -> Registry[Any]:
])


class JsonValidator(_BaseJsonValidator, Validator):
class JsonValidator(_BaseJsonValidator, BaseSchemaBasedValidator, SchemaBasedValidator):
"""Validator for CycloneDX documents in JSON format."""

@property
def _schema_file(self) -> Optional[str]:
return _S_BOM.get(self.schema_version)


class JsonStrictValidator(_BaseJsonValidator, Validator):
class JsonStrictValidator(_BaseJsonValidator, BaseSchemaBasedValidator, SchemaBasedValidator):
"""Strict validator for CycloneDX documents in JSON format.
In contrast to :class:`~JsonValidator`,
Expand Down
20 changes: 20 additions & 0 deletions cyclonedx/validation/model.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
# Copyright (c) OWASP Foundation. All Rights Reserved.


# nothing here, yet.
# in the future this could be the place where model validation is done.
# like the current `model.bom.Bom.validate()`
# see also: https://github.com/CycloneDX/cyclonedx-python-lib/issues/455
61 changes: 61 additions & 0 deletions cyclonedx/validation/schema.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
# Copyright (c) OWASP Foundation. All Rights Reserved.


from typing import TYPE_CHECKING, Literal, Union, overload

from ..schema import OutputFormat

if TYPE_CHECKING: # pragma: no cover
from ..schema import SchemaVersion
from . import BaseSchemaBasedValidator
from .json import JsonValidator
from .xml import XmlValidator


@overload
def get_instance(output_format: Literal[OutputFormat.JSON], schema_version: 'SchemaVersion'
) -> 'JsonValidator':
...


@overload
def get_instance(output_format: Literal[OutputFormat.XML], schema_version: 'SchemaVersion'
) -> 'XmlValidator':
...


@overload
def get_instance(output_format: OutputFormat, schema_version: 'SchemaVersion'
) -> Union['JsonValidator', 'XmlValidator']:
...


def get_instance(output_format: OutputFormat, schema_version: 'SchemaVersion') -> 'BaseSchemaBasedValidator':
"""get the default schema-based validator for a certain `OutputFormat`
Raises error when no instance could be built.
"""
# all exceptions are undocumented, as they are pure functional, and should be prevented by correct typing...
if TYPE_CHECKING: # pragma: no cover
from typing import Type
Validator: Type[BaseSchemaBasedValidator]
if OutputFormat.JSON is output_format:
from .json import JsonValidator as Validator
elif OutputFormat.XML is output_format:
from .xml import XmlValidator as Validator
else:
raise ValueError(f'Unexpected output_format: {output_format!r}')
return Validator(schema_version)
6 changes: 3 additions & 3 deletions cyclonedx/validation/xml.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
from ..exception import MissingOptionalDependencyException
from ..schema import OutputFormat
from ..schema._res import BOM_XML as _S_BOM
from . import BaseValidator, ValidationError, Validator
from . import BaseSchemaBasedValidator, SchemaBasedValidator, ValidationError

if TYPE_CHECKING: # pragma: no cover
from ..schema import SchemaVersion
Expand All @@ -37,7 +37,7 @@
), err


class _BaseXmlValidator(BaseValidator, ABC):
class _BaseXmlValidator(BaseSchemaBasedValidator, ABC):

@property
def output_format(self) -> Literal[OutputFormat.XML]:
Expand Down Expand Up @@ -86,7 +86,7 @@ def _validator(self) -> 'XMLSchema':
return self.__validator


class XmlValidator(_BaseXmlValidator, Validator):
class XmlValidator(_BaseXmlValidator, BaseSchemaBasedValidator, SchemaBasedValidator):
"""Validator for CycloneDX documents in XML format."""

@property
Expand Down
2 changes: 1 addition & 1 deletion examples/complex.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
from cyclonedx.output.json import JsonV1Dot4
from cyclonedx.schema import SchemaVersion, OutputFormat
from cyclonedx.validation.json import JsonStrictValidator
from cyclonedx.validation import get_instance as get_validator
from cyclonedx.validation.schema import get_instance as get_validator

from typing import TYPE_CHECKING

Expand Down
4 changes: 2 additions & 2 deletions tests/test_output.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,8 @@ def test_as_expected(self, of: OutputFormat, sv: SchemaVersion) -> None:
self.assertIs(outputter.schema_version, sv)

@data(
*((of, 'foo', (TypeError, "unexpected schema_version: 'foo'")) for of in OutputFormat),
*(('foo', sv, (TypeError, "unexpected output_format: 'foo'")) for sv in SchemaVersion),
*((of, 'foo', (ValueError, f"Unknown {of.name}/schema_version: 'foo'")) for of in OutputFormat),
*(('foo', sv, (ValueError, "Unexpected output_format: 'foo'")) for sv in SchemaVersion),
)
@unpack
def test_fails_on_wrong_args(self, of: OutputFormat, sv: SchemaVersion, raisesRegex: Tuple) -> None:
Expand Down
4 changes: 2 additions & 2 deletions tests/test_validation_json.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ def test_validator_as_expected(self, schema_version: SchemaVersion) -> None:

@idata(UNSUPPORTED_SCHEMA_VERSIONS)
def test_throws_with_unsupported_schema_version(self, schema_version: SchemaVersion) -> None:
with self.assertRaisesRegex(ValueError, f'unsupported schema_version: {schema_version}'):
with self.assertRaisesRegex(ValueError, 'Unsupported schema_version'):
JsonValidator(schema_version)

@idata(_dp('valid'))
Expand Down Expand Up @@ -82,7 +82,7 @@ class TestJsonStrictValidator(TestCase):

@data(*UNSUPPORTED_SCHEMA_VERSIONS)
def test_throws_with_unsupported_schema_version(self, schema_version: SchemaVersion) -> None:
with self.assertRaisesRegex(ValueError, f'unsupported schema_version: {schema_version}'):
with self.assertRaisesRegex(ValueError, 'Unsupported schema_version'):
JsonStrictValidator(schema_version)

@idata(_dp('valid'))
Expand Down
6 changes: 3 additions & 3 deletions tests/test_validation.py → tests/test_validation_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
from ddt import data, ddt, named_data, unpack

from cyclonedx.schema import OutputFormat, SchemaVersion
from cyclonedx.validation import get_instance as get_validator
from cyclonedx.validation.schema import get_instance as get_validator

UNDEFINED_FORMAT_VERSION = {
(OutputFormat.JSON, SchemaVersion.V1_1),
Expand All @@ -45,8 +45,8 @@ def test_as_expected(self, of: OutputFormat, sv: SchemaVersion) -> None:
self.assertIs(validator.schema_version, sv)

@data(
*(('foo', sv, (TypeError, "unexpected output_format: 'foo'")) for sv in SchemaVersion),
*((f, v, (ValueError, f'unsupported schema_version: {v}')) for f, v in UNDEFINED_FORMAT_VERSION)
*(('foo', sv, (ValueError, 'Unexpected output_format')) for sv in SchemaVersion),
*((f, v, (ValueError, 'Unsupported schema_version')) for f, v in UNDEFINED_FORMAT_VERSION)
)
@unpack
def test_fails_on_wrong_args(self, of: OutputFormat, sv: SchemaVersion, raisesRegex: Tuple) -> None:
Expand Down

0 comments on commit 65e79cf

Please sign in to comment.