Skip to content

Commit

Permalink
proper enums
Browse files Browse the repository at this point in the history
fixes #442
part of #446

BREAKING CHANGE

Signed-off-by: Jan Kowalleck <jan.kowalleck@gmail.com>
  • Loading branch information
jkowalleck committed Sep 21, 2023
1 parent 3de2493 commit 6a6598e
Show file tree
Hide file tree
Showing 12 changed files with 311 additions and 71 deletions.
38 changes: 22 additions & 16 deletions cyclonedx/output/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,10 @@
and according to different versions of the CycloneDX schema standard.
"""

import importlib
import os
from abc import ABC, abstractmethod
from typing import Iterable, Union, cast
from importlib import import_module
from typing import Iterable, Optional, Type, Union

from ..model.bom import Bom
from ..model.component import Component
Expand All @@ -46,7 +46,12 @@ def _chained_components(self, container: Union[Bom, Component]) -> Iterable[Comp
@property
@abstractmethod
def schema_version(self) -> SchemaVersion:
pass
...

@property
@abstractmethod
def output_format(self) -> OutputFormat:
...

@property
def generated(self) -> bool:
Expand All @@ -64,28 +69,23 @@ def set_bom(self, bom: Bom) -> None:

@abstractmethod
def generate(self, force_regeneration: bool = False) -> None:
pass
...

@abstractmethod
def output_as_string(self) -> str:
pass
...

def output_to_file(self, filename: str, allow_overwrite: bool = False) -> None:
# Check directory writable
output_filename = os.path.realpath(filename)
output_directory = os.path.dirname(output_filename)

if not os.access(output_directory, os.W_OK):
raise PermissionError(output_directory)

if os.path.exists(output_filename) and not allow_overwrite:
raise FileExistsError(output_filename)

with open(output_filename, mode='wb') as f_out:
f_out.write(self.output_as_string().encode('utf-8'))

f_out.close()


def get_instance(bom: Bom, output_format: OutputFormat = OutputFormat.XML,
schema_version: SchemaVersion = LATEST_SUPPORTED_SCHEMA_VERSION) -> BaseOutput:
Expand All @@ -99,10 +99,16 @@ def get_instance(bom: Bom, output_format: OutputFormat = OutputFormat.XML,
:param schema_version: SchemaVersion
:return:
"""
# 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 = importlib.import_module(f"cyclonedx.output.{output_format.value.lower()}")
output_klass = getattr(module, f"{output_format.value}{schema_version.value}")
except (ImportError, AttributeError) as e:
raise ValueError(f"Unknown format {output_format.value.lower()!r}: {e}") from None

return cast(BaseOutput, output_klass(bom=bom))
module = import_module(f'.{output_format.name.lower()}', __package__)
except ImportError as error: # pragma: no cover
raise ValueError(f'Unknown output_format: {output_format.name}') from error
output_klass: Optional[Type[BaseOutput]] = module.BY_SCHEMA_VERSION.get(schema_version, None)
if output_klass is None: # pragma: no cover
raise ValueError(f'Unknown {output_format.name}/schema_version: {schema_version.name}')
return output_klass(bom=bom)
19 changes: 16 additions & 3 deletions cyclonedx/output/json.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,11 @@

import json
from abc import abstractmethod
from typing import Optional
from typing import Dict, Optional, Type

from ..exception.output import FormatNotSupportedException
from ..model.bom import Bom
from ..schema import SchemaVersion
from ..schema import OutputFormat, SchemaVersion
from ..schema.schema import (
SCHEMA_VERSIONS,
BaseSchemaVersion,
Expand All @@ -46,6 +46,10 @@ def __init__(self, bom: Bom) -> None:
def schema_version(self) -> SchemaVersion:
return self.schema_version_enum

@property
def output_format(self) -> OutputFormat:
return OutputFormat.JSON

def generate(self, force_regeneration: bool = False) -> None:
# New Way
schema_uri: Optional[str] = self._get_schema_uri()
Expand All @@ -58,7 +62,7 @@ def generate(self, force_regeneration: bool = False) -> None:
'bomFormat': 'CycloneDX',
'specVersion': self.schema_version.to_version()
}
_view = SCHEMA_VERSIONS.get(self.get_schema_version())
_view = SCHEMA_VERSIONS.get(self.schema_version_enum)
if self.generated and force_regeneration:
self.get_bom().validate()
bom_json = json.loads(self.get_bom().as_json(view_=_view)) # type: ignore
Expand Down Expand Up @@ -113,3 +117,12 @@ class JsonV1Dot4(Json, SchemaVersion1Dot4):

def _get_schema_uri(self) -> Optional[str]:
return 'http://cyclonedx.org/schema/bom-1.4.schema.json'


BY_SCHEMA_VERSION: Dict[SchemaVersion, Type[Json]] = {
SchemaVersion.V1_4: JsonV1Dot4, # type:ignore[type-abstract]
SchemaVersion.V1_3: JsonV1Dot3, # type:ignore[type-abstract]
SchemaVersion.V1_2: JsonV1Dot2, # type:ignore[type-abstract]
SchemaVersion.V1_1: JsonV1Dot1, # type:ignore[type-abstract]
SchemaVersion.V1_0: JsonV1Dot0, # type:ignore[type-abstract]
}
51 changes: 32 additions & 19 deletions cyclonedx/output/xml.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,22 @@
# encoding: utf-8

from typing import Dict, Optional, Type
from xml.etree import ElementTree

from ..exception.output import BomGenerationErrorException
from ..model.bom import Bom
from ..schema import OutputFormat, SchemaVersion
from ..schema.schema import (
SCHEMA_VERSIONS,
BaseSchemaVersion,
SchemaVersion1Dot0,
SchemaVersion1Dot1,
SchemaVersion1Dot2,
SchemaVersion1Dot3,
SchemaVersion1Dot4,
)
from . import BaseOutput

# This file is part of CycloneDX Python Lib
#
# Licensed under the Apache License, Version 2.0 (the "License");
Expand All @@ -17,25 +34,8 @@
# SPDX-License-Identifier: Apache-2.0
# Copyright (c) OWASP Foundation. All Rights Reserved.

from typing import Optional
from xml.etree import ElementTree

from ..exception.output import BomGenerationErrorException
from ..model.bom import Bom
from ..schema import SchemaVersion
from ..schema.schema import (
SCHEMA_VERSIONS,
BaseSchemaVersion,
SchemaVersion1Dot0,
SchemaVersion1Dot1,
SchemaVersion1Dot2,
SchemaVersion1Dot3,
SchemaVersion1Dot4,
)
from . import BaseOutput


class Xml(BaseOutput, BaseSchemaVersion):
class Xml(BaseSchemaVersion, BaseOutput):
XML_VERSION_DECLARATION: str = '<?xml version="1.0" encoding="UTF-8"?>'

def __init__(self, bom: Bom) -> None:
Expand All @@ -46,9 +46,13 @@ def __init__(self, bom: Bom) -> None:
def schema_version(self) -> SchemaVersion:
return self.schema_version_enum

@property
def output_format(self) -> OutputFormat:
return OutputFormat.XML

def generate(self, force_regeneration: bool = False) -> None:
# New way
_view = SCHEMA_VERSIONS.get(self.get_schema_version())
_view = SCHEMA_VERSIONS[self.schema_version_enum]
if self.generated and force_regeneration:
self.get_bom().validate()
self._root_bom_element = self.get_bom().as_xml( # type: ignore
Expand Down Expand Up @@ -97,3 +101,12 @@ class XmlV1Dot3(Xml, SchemaVersion1Dot3):

class XmlV1Dot4(Xml, SchemaVersion1Dot4):
pass


BY_SCHEMA_VERSION: Dict[SchemaVersion, Type[Xml]] = {
SchemaVersion.V1_4: XmlV1Dot4, # type:ignore[type-abstract]
SchemaVersion.V1_3: XmlV1Dot3, # type:ignore[type-abstract]
SchemaVersion.V1_2: XmlV1Dot2, # type:ignore[type-abstract]
SchemaVersion.V1_1: XmlV1Dot1, # type:ignore[type-abstract]
SchemaVersion.V1_0: XmlV1Dot0, # type:ignore[type-abstract]
}
79 changes: 63 additions & 16 deletions cyclonedx/schema/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,26 +14,73 @@
#
# SPDX-License-Identifier: Apache-2.0

from enum import Enum
from enum import Enum, auto, unique


class OutputFormat(str, Enum):
JSON: str = 'Json'
XML: str = 'Xml'
@unique
class OutputFormat(Enum):
"""Output formats.
Do not rely on the actual/literal values, just use enum cases.
"""
JSON = auto()
XML = auto()

class SchemaVersion(str, Enum):
V1_0: str = 'V1Dot0'
V1_1: str = 'V1Dot1'
V1_2: str = 'V1Dot2'
V1_3: str = 'V1Dot3'
V1_4: str = 'V1Dot4'

@unique
class SchemaVersion(Enum):
"""
Schema version.
Cases are hashable.
Cases are comparable(!=,>=,>,==,<,<=)
Do not rely on the actual/literal values, just use enum cases.
"""
V1_4 = (1, 4)
V1_3 = (1, 3)
V1_2 = (1, 2)
V1_1 = (1, 1)
V1_0 = (1, 0)

@classmethod
def from_version(cls, version: str) -> 'SchemaVersion':
"""Return instance from a version string - e.g. `1.4`"""
return cls(tuple(map(int, version.split('.')))[:2])

def to_version(self) -> str:
"""
Return as a version string - e.g. `1.4`
"""Return as a version string - e.g. `1.4`"""
return '.'.join(map(str, self.value))

def __ne__(self, other: object) -> bool:
return self.value != other.value \
if isinstance(other, self.__class__) \
else NotImplemented # type:ignore[return-value]

def __lt__(self, other: object) -> bool:
return self.value < other.value \
if isinstance(other, self.__class__) \
else NotImplemented # type:ignore[return-value]

def __le__(self, other: object) -> bool:
return self.value <= other.value \
if isinstance(other, self.__class__) \
else NotImplemented # type:ignore[return-value]

def __eq__(self, other: object) -> bool:
return self.value == other.value \
if isinstance(other, self.__class__) \
else NotImplemented # type:ignore[return-value]

def __ge__(self, other: object) -> bool:
return self.value >= other.value \
if isinstance(other, self.__class__) \
else NotImplemented # type:ignore[return-value]

def __gt__(self, other: object) -> bool:
return self.value > other.value \
if isinstance(other, self.__class__) \
else NotImplemented # type:ignore[return-value]

Returns:
`str` version
"""
return f'{self.value[1]}.{self.value[5]}'
def __hash__(self) -> int:
return hash(self.name)
15 changes: 8 additions & 7 deletions cyclonedx/schema/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
# Copyright (c) OWASP Foundation. All Rights Reserved.

from abc import ABC, abstractmethod
from typing import Dict, Type

from serializable import ViewType

Expand All @@ -29,7 +30,7 @@ class BaseSchemaVersion(ABC, ViewType):
@property
@abstractmethod
def schema_version_enum(self) -> SchemaVersion:
pass
...

def get_schema_version(self) -> str:
return self.schema_version_enum.to_version()
Expand Down Expand Up @@ -70,10 +71,10 @@ def schema_version_enum(self) -> SchemaVersion:
return SchemaVersion.V1_0


SCHEMA_VERSIONS = {
'1.0': SchemaVersion1Dot0,
'1.1': SchemaVersion1Dot1,
'1.2': SchemaVersion1Dot2,
'1.3': SchemaVersion1Dot3,
'1.4': SchemaVersion1Dot4
SCHEMA_VERSIONS: Dict[SchemaVersion, Type[BaseSchemaVersion]] = {
SchemaVersion.V1_4: SchemaVersion1Dot4, # type:ignore[type-abstract]
SchemaVersion.V1_3: SchemaVersion1Dot3, # type:ignore[type-abstract]
SchemaVersion.V1_2: SchemaVersion1Dot2, # type:ignore[type-abstract]
SchemaVersion.V1_1: SchemaVersion1Dot1, # type:ignore[type-abstract]
SchemaVersion.V1_0: SchemaVersion1Dot0, # type:ignore[type-abstract]
}
53 changes: 53 additions & 0 deletions tests/test_output.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# encoding: utf-8

# This file is part of CycloneDX Python Lib
#
# 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 itertools import product
from typing import Tuple
from unittest import TestCase
from unittest.mock import Mock

from ddt import data, ddt, named_data, unpack

from cyclonedx.model.bom import Bom
from cyclonedx.output import get_instance as get_outputter
from cyclonedx.schema import OutputFormat, SchemaVersion


@ddt
class Test(TestCase):

@named_data(*([f'{x[0].name} {x[1].name}', *x] for x in product(OutputFormat, SchemaVersion)))
@unpack
def test_get_instance_expected(self, of: OutputFormat, sv: SchemaVersion) -> None:
bom = Mock(spec=Bom)
outputter = get_outputter(bom, of, sv)
self.assertIs(outputter.get_bom(), bom)
self.assertIs(outputter.output_format, of)
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),
)
@unpack
def test_get_instance_fails(self, of: OutputFormat, sv: SchemaVersion, raisesRegex: Tuple) -> None:
bom = Mock(spec=Bom)
with self.assertRaisesRegexp(*raisesRegex):
get_outputter(bom, of, sv)
Loading

0 comments on commit 6a6598e

Please sign in to comment.