Skip to content

Commit 6a6598e

Browse files
committed
proper enums
fixes #442 part of #446 BREAKING CHANGE Signed-off-by: Jan Kowalleck <[email protected]>
1 parent 3de2493 commit 6a6598e

12 files changed

+311
-71
lines changed

cyclonedx/output/__init__.py

+22-16
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,10 @@
1919
and according to different versions of the CycloneDX schema standard.
2020
"""
2121

22-
import importlib
2322
import os
2423
from abc import ABC, abstractmethod
25-
from typing import Iterable, Union, cast
24+
from importlib import import_module
25+
from typing import Iterable, Optional, Type, Union
2626

2727
from ..model.bom import Bom
2828
from ..model.component import Component
@@ -46,7 +46,12 @@ def _chained_components(self, container: Union[Bom, Component]) -> Iterable[Comp
4646
@property
4747
@abstractmethod
4848
def schema_version(self) -> SchemaVersion:
49-
pass
49+
...
50+
51+
@property
52+
@abstractmethod
53+
def output_format(self) -> OutputFormat:
54+
...
5055

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

6570
@abstractmethod
6671
def generate(self, force_regeneration: bool = False) -> None:
67-
pass
72+
...
6873

6974
@abstractmethod
7075
def output_as_string(self) -> str:
71-
pass
76+
...
7277

7378
def output_to_file(self, filename: str, allow_overwrite: bool = False) -> None:
7479
# Check directory writable
7580
output_filename = os.path.realpath(filename)
7681
output_directory = os.path.dirname(output_filename)
77-
7882
if not os.access(output_directory, os.W_OK):
7983
raise PermissionError(output_directory)
80-
8184
if os.path.exists(output_filename) and not allow_overwrite:
8285
raise FileExistsError(output_filename)
83-
8486
with open(output_filename, mode='wb') as f_out:
8587
f_out.write(self.output_as_string().encode('utf-8'))
8688

87-
f_out.close()
88-
8989

9090
def get_instance(bom: Bom, output_format: OutputFormat = OutputFormat.XML,
9191
schema_version: SchemaVersion = LATEST_SUPPORTED_SCHEMA_VERSION) -> BaseOutput:
@@ -99,10 +99,16 @@ def get_instance(bom: Bom, output_format: OutputFormat = OutputFormat.XML,
9999
:param schema_version: SchemaVersion
100100
:return:
101101
"""
102+
# all exceptions are undocumented, as they are pure functional, and should be prevented by correct typing...
103+
if not isinstance(output_format, OutputFormat):
104+
raise TypeError(f"unexpected output_format: {output_format!r}")
105+
if not isinstance(schema_version, SchemaVersion):
106+
raise TypeError(f"unexpected schema_version: {schema_version!r}")
102107
try:
103-
module = importlib.import_module(f"cyclonedx.output.{output_format.value.lower()}")
104-
output_klass = getattr(module, f"{output_format.value}{schema_version.value}")
105-
except (ImportError, AttributeError) as e:
106-
raise ValueError(f"Unknown format {output_format.value.lower()!r}: {e}") from None
107-
108-
return cast(BaseOutput, output_klass(bom=bom))
108+
module = import_module(f'.{output_format.name.lower()}', __package__)
109+
except ImportError as error: # pragma: no cover
110+
raise ValueError(f'Unknown output_format: {output_format.name}') from error
111+
output_klass: Optional[Type[BaseOutput]] = module.BY_SCHEMA_VERSION.get(schema_version, None)
112+
if output_klass is None: # pragma: no cover
113+
raise ValueError(f'Unknown {output_format.name}/schema_version: {schema_version.name}')
114+
return output_klass(bom=bom)

cyclonedx/output/json.py

+16-3
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,11 @@
1919

2020
import json
2121
from abc import abstractmethod
22-
from typing import Optional
22+
from typing import Dict, Optional, Type
2323

2424
from ..exception.output import FormatNotSupportedException
2525
from ..model.bom import Bom
26-
from ..schema import SchemaVersion
26+
from ..schema import OutputFormat, SchemaVersion
2727
from ..schema.schema import (
2828
SCHEMA_VERSIONS,
2929
BaseSchemaVersion,
@@ -46,6 +46,10 @@ def __init__(self, bom: Bom) -> None:
4646
def schema_version(self) -> SchemaVersion:
4747
return self.schema_version_enum
4848

49+
@property
50+
def output_format(self) -> OutputFormat:
51+
return OutputFormat.JSON
52+
4953
def generate(self, force_regeneration: bool = False) -> None:
5054
# New Way
5155
schema_uri: Optional[str] = self._get_schema_uri()
@@ -58,7 +62,7 @@ def generate(self, force_regeneration: bool = False) -> None:
5862
'bomFormat': 'CycloneDX',
5963
'specVersion': self.schema_version.to_version()
6064
}
61-
_view = SCHEMA_VERSIONS.get(self.get_schema_version())
65+
_view = SCHEMA_VERSIONS.get(self.schema_version_enum)
6266
if self.generated and force_regeneration:
6367
self.get_bom().validate()
6468
bom_json = json.loads(self.get_bom().as_json(view_=_view)) # type: ignore
@@ -113,3 +117,12 @@ class JsonV1Dot4(Json, SchemaVersion1Dot4):
113117

114118
def _get_schema_uri(self) -> Optional[str]:
115119
return 'http://cyclonedx.org/schema/bom-1.4.schema.json'
120+
121+
122+
BY_SCHEMA_VERSION: Dict[SchemaVersion, Type[Json]] = {
123+
SchemaVersion.V1_4: JsonV1Dot4, # type:ignore[type-abstract]
124+
SchemaVersion.V1_3: JsonV1Dot3, # type:ignore[type-abstract]
125+
SchemaVersion.V1_2: JsonV1Dot2, # type:ignore[type-abstract]
126+
SchemaVersion.V1_1: JsonV1Dot1, # type:ignore[type-abstract]
127+
SchemaVersion.V1_0: JsonV1Dot0, # type:ignore[type-abstract]
128+
}

cyclonedx/output/xml.py

+32-19
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,22 @@
11
# encoding: utf-8
22

3+
from typing import Dict, Optional, Type
4+
from xml.etree import ElementTree
5+
6+
from ..exception.output import BomGenerationErrorException
7+
from ..model.bom import Bom
8+
from ..schema import OutputFormat, SchemaVersion
9+
from ..schema.schema import (
10+
SCHEMA_VERSIONS,
11+
BaseSchemaVersion,
12+
SchemaVersion1Dot0,
13+
SchemaVersion1Dot1,
14+
SchemaVersion1Dot2,
15+
SchemaVersion1Dot3,
16+
SchemaVersion1Dot4,
17+
)
18+
from . import BaseOutput
19+
320
# This file is part of CycloneDX Python Lib
421
#
522
# Licensed under the Apache License, Version 2.0 (the "License");
@@ -17,25 +34,8 @@
1734
# SPDX-License-Identifier: Apache-2.0
1835
# Copyright (c) OWASP Foundation. All Rights Reserved.
1936

20-
from typing import Optional
21-
from xml.etree import ElementTree
22-
23-
from ..exception.output import BomGenerationErrorException
24-
from ..model.bom import Bom
25-
from ..schema import SchemaVersion
26-
from ..schema.schema import (
27-
SCHEMA_VERSIONS,
28-
BaseSchemaVersion,
29-
SchemaVersion1Dot0,
30-
SchemaVersion1Dot1,
31-
SchemaVersion1Dot2,
32-
SchemaVersion1Dot3,
33-
SchemaVersion1Dot4,
34-
)
35-
from . import BaseOutput
36-
3737

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

4141
def __init__(self, bom: Bom) -> None:
@@ -46,9 +46,13 @@ def __init__(self, bom: Bom) -> None:
4646
def schema_version(self) -> SchemaVersion:
4747
return self.schema_version_enum
4848

49+
@property
50+
def output_format(self) -> OutputFormat:
51+
return OutputFormat.XML
52+
4953
def generate(self, force_regeneration: bool = False) -> None:
5054
# New way
51-
_view = SCHEMA_VERSIONS.get(self.get_schema_version())
55+
_view = SCHEMA_VERSIONS[self.schema_version_enum]
5256
if self.generated and force_regeneration:
5357
self.get_bom().validate()
5458
self._root_bom_element = self.get_bom().as_xml( # type: ignore
@@ -97,3 +101,12 @@ class XmlV1Dot3(Xml, SchemaVersion1Dot3):
97101

98102
class XmlV1Dot4(Xml, SchemaVersion1Dot4):
99103
pass
104+
105+
106+
BY_SCHEMA_VERSION: Dict[SchemaVersion, Type[Xml]] = {
107+
SchemaVersion.V1_4: XmlV1Dot4, # type:ignore[type-abstract]
108+
SchemaVersion.V1_3: XmlV1Dot3, # type:ignore[type-abstract]
109+
SchemaVersion.V1_2: XmlV1Dot2, # type:ignore[type-abstract]
110+
SchemaVersion.V1_1: XmlV1Dot1, # type:ignore[type-abstract]
111+
SchemaVersion.V1_0: XmlV1Dot0, # type:ignore[type-abstract]
112+
}

cyclonedx/schema/__init__.py

+63-16
Original file line numberDiff line numberDiff line change
@@ -14,26 +14,73 @@
1414
#
1515
# SPDX-License-Identifier: Apache-2.0
1616

17-
from enum import Enum
17+
from enum import Enum, auto, unique
1818

1919

20-
class OutputFormat(str, Enum):
21-
JSON: str = 'Json'
22-
XML: str = 'Xml'
20+
@unique
21+
class OutputFormat(Enum):
22+
"""Output formats.
2323
24+
Do not rely on the actual/literal values, just use enum cases.
25+
"""
26+
JSON = auto()
27+
XML = auto()
2428

25-
class SchemaVersion(str, Enum):
26-
V1_0: str = 'V1Dot0'
27-
V1_1: str = 'V1Dot1'
28-
V1_2: str = 'V1Dot2'
29-
V1_3: str = 'V1Dot3'
30-
V1_4: str = 'V1Dot4'
29+
30+
@unique
31+
class SchemaVersion(Enum):
32+
"""
33+
Schema version.
34+
35+
Cases are hashable.
36+
Cases are comparable(!=,>=,>,==,<,<=)
37+
38+
Do not rely on the actual/literal values, just use enum cases.
39+
"""
40+
V1_4 = (1, 4)
41+
V1_3 = (1, 3)
42+
V1_2 = (1, 2)
43+
V1_1 = (1, 1)
44+
V1_0 = (1, 0)
45+
46+
@classmethod
47+
def from_version(cls, version: str) -> 'SchemaVersion':
48+
"""Return instance from a version string - e.g. `1.4`"""
49+
return cls(tuple(map(int, version.split('.')))[:2])
3150

3251
def to_version(self) -> str:
33-
"""
34-
Return as a version string - e.g. `1.4`
52+
"""Return as a version string - e.g. `1.4`"""
53+
return '.'.join(map(str, self.value))
54+
55+
def __ne__(self, other: object) -> bool:
56+
return self.value != other.value \
57+
if isinstance(other, self.__class__) \
58+
else NotImplemented # type:ignore[return-value]
59+
60+
def __lt__(self, other: object) -> bool:
61+
return self.value < other.value \
62+
if isinstance(other, self.__class__) \
63+
else NotImplemented # type:ignore[return-value]
64+
65+
def __le__(self, other: object) -> bool:
66+
return self.value <= other.value \
67+
if isinstance(other, self.__class__) \
68+
else NotImplemented # type:ignore[return-value]
69+
70+
def __eq__(self, other: object) -> bool:
71+
return self.value == other.value \
72+
if isinstance(other, self.__class__) \
73+
else NotImplemented # type:ignore[return-value]
74+
75+
def __ge__(self, other: object) -> bool:
76+
return self.value >= other.value \
77+
if isinstance(other, self.__class__) \
78+
else NotImplemented # type:ignore[return-value]
79+
80+
def __gt__(self, other: object) -> bool:
81+
return self.value > other.value \
82+
if isinstance(other, self.__class__) \
83+
else NotImplemented # type:ignore[return-value]
3584

36-
Returns:
37-
`str` version
38-
"""
39-
return f'{self.value[1]}.{self.value[5]}'
85+
def __hash__(self) -> int:
86+
return hash(self.name)

cyclonedx/schema/schema.py

+8-7
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
# Copyright (c) OWASP Foundation. All Rights Reserved.
1919

2020
from abc import ABC, abstractmethod
21+
from typing import Dict, Type
2122

2223
from serializable import ViewType
2324

@@ -29,7 +30,7 @@ class BaseSchemaVersion(ABC, ViewType):
2930
@property
3031
@abstractmethod
3132
def schema_version_enum(self) -> SchemaVersion:
32-
pass
33+
...
3334

3435
def get_schema_version(self) -> str:
3536
return self.schema_version_enum.to_version()
@@ -70,10 +71,10 @@ def schema_version_enum(self) -> SchemaVersion:
7071
return SchemaVersion.V1_0
7172

7273

73-
SCHEMA_VERSIONS = {
74-
'1.0': SchemaVersion1Dot0,
75-
'1.1': SchemaVersion1Dot1,
76-
'1.2': SchemaVersion1Dot2,
77-
'1.3': SchemaVersion1Dot3,
78-
'1.4': SchemaVersion1Dot4
74+
SCHEMA_VERSIONS: Dict[SchemaVersion, Type[BaseSchemaVersion]] = {
75+
SchemaVersion.V1_4: SchemaVersion1Dot4, # type:ignore[type-abstract]
76+
SchemaVersion.V1_3: SchemaVersion1Dot3, # type:ignore[type-abstract]
77+
SchemaVersion.V1_2: SchemaVersion1Dot2, # type:ignore[type-abstract]
78+
SchemaVersion.V1_1: SchemaVersion1Dot1, # type:ignore[type-abstract]
79+
SchemaVersion.V1_0: SchemaVersion1Dot0, # type:ignore[type-abstract]
7980
}

tests/test_output.py

+53
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
# encoding: utf-8
2+
3+
# This file is part of CycloneDX Python Lib
4+
#
5+
# Licensed under the Apache License, Version 2.0 (the "License");
6+
# you may not use this file except in compliance with the License.
7+
# You may obtain a copy of the License at
8+
#
9+
# http://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing, software
12+
# distributed under the License is distributed on an "AS IS" BASIS,
13+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
# See the License for the specific language governing permissions and
15+
# limitations under the License.
16+
#
17+
# SPDX-License-Identifier: Apache-2.0
18+
# Copyright (c) OWASP Foundation. All Rights Reserved.
19+
20+
21+
from itertools import product
22+
from typing import Tuple
23+
from unittest import TestCase
24+
from unittest.mock import Mock
25+
26+
from ddt import data, ddt, named_data, unpack
27+
28+
from cyclonedx.model.bom import Bom
29+
from cyclonedx.output import get_instance as get_outputter
30+
from cyclonedx.schema import OutputFormat, SchemaVersion
31+
32+
33+
@ddt
34+
class Test(TestCase):
35+
36+
@named_data(*([f'{x[0].name} {x[1].name}', *x] for x in product(OutputFormat, SchemaVersion)))
37+
@unpack
38+
def test_get_instance_expected(self, of: OutputFormat, sv: SchemaVersion) -> None:
39+
bom = Mock(spec=Bom)
40+
outputter = get_outputter(bom, of, sv)
41+
self.assertIs(outputter.get_bom(), bom)
42+
self.assertIs(outputter.output_format, of)
43+
self.assertIs(outputter.schema_version, sv)
44+
45+
@data(
46+
*((of, 'foo', (TypeError, "unexpected schema_version: 'foo'")) for of in OutputFormat),
47+
*(('foo', sv, (TypeError, "unexpected output_format: 'foo'")) for sv in SchemaVersion),
48+
)
49+
@unpack
50+
def test_get_instance_fails(self, of: OutputFormat, sv: SchemaVersion, raisesRegex: Tuple) -> None:
51+
bom = Mock(spec=Bom)
52+
with self.assertRaisesRegexp(*raisesRegex):
53+
get_outputter(bom, of, sv)

0 commit comments

Comments
 (0)