Skip to content

Commit 2bbd659

Browse files
committed
CycloneDX#561: Add component and services for tools
CycloneDX spec 1.5 depcreated an array of tools in bom.metadata and instead prefers object with an array of components and an array of services. This PR implements that. Signed-off-by: Joshua Kugler <[email protected]>
1 parent a8a4ac9 commit 2bbd659

File tree

2 files changed

+250
-9
lines changed

2 files changed

+250
-9
lines changed

cyclonedx/model/bom.py

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818

1919
from datetime import datetime
2020
from itertools import chain
21-
from typing import TYPE_CHECKING, Generator, Iterable, Optional, Union
21+
from typing import TYPE_CHECKING, Any, AnyStr, Dict, Generator, Iterable, Optional, Union
2222
from uuid import UUID, uuid4
2323
from warnings import warn
2424

@@ -27,6 +27,7 @@
2727

2828
from .._internal.time import get_now_utc as _get_now_utc
2929
from ..exception.model import LicenseExpressionAlongWithOthersException, UnknownComponentDependencyException
30+
from ..model.tool import Tool, ToolRepository, ToolRepositoryHelper
3031
from ..schema.schema import (
3132
SchemaVersion1Dot0,
3233
SchemaVersion1Dot1,
@@ -37,7 +38,7 @@
3738
SchemaVersion1Dot6,
3839
)
3940
from ..serialization import LicenseRepositoryHelper, UrnUuidHelper
40-
from . import ExternalReference, Property, ThisTool, Tool
41+
from . import ExternalReference, Property, ThisTool
4142
from .bom_ref import BomRef
4243
from .component import Component
4344
from .contact import OrganizationalContact, OrganizationalEntity
@@ -59,7 +60,7 @@ class BomMetaData:
5960
See the CycloneDX Schema for Bom metadata: https://cyclonedx.org/docs/1.5/#type_metadata
6061
"""
6162

62-
def __init__(self, *, tools: Optional[Iterable[Tool]] = None,
63+
def __init__(self, *, tools: Optional[Union[Iterable[Tool], Dict[AnyStr, Any]]] = None,
6364
authors: Optional[Iterable[OrganizationalContact]] = None, component: Optional[Component] = None,
6465
supplier: Optional[OrganizationalEntity] = None,
6566
licenses: Optional[Iterable[License]] = None,
@@ -115,22 +116,30 @@ def timestamp(self, timestamp: datetime) -> None:
115116
# ... # TODO since CDX1.5
116117

117118
@property
119+
@serializable.type_mapping(ToolRepositoryHelper)
118120
@serializable.xml_array(serializable.XmlArraySerializationType.NESTED, 'tool')
119121
@serializable.xml_sequence(3)
120-
def tools(self) -> 'SortedSet[Tool]':
122+
def tools(self) -> ToolRepository:
121123
"""
122124
Tools used to create this BOM.
123125
124126
Returns:
125127
`Set` of `Tool` objects.
126128
"""
127-
# TODO since CDX1.5 also supports `Component` and `Services`, not only `Tool`
128-
return self._tools
129+
return self._tools # type: ignore
129130

130131
@tools.setter
131-
def tools(self, tools: Iterable[Tool]) -> None:
132-
# TODO since CDX1.5 also supports `Component` and `Services`, not only `Tool`
133-
self._tools = SortedSet(tools)
132+
def tools(self, tools: Union[ToolRepository, Iterable[Tool]]) -> None:
133+
if isinstance(tools, ToolRepository):
134+
self._tools = tools
135+
else:
136+
# This allows the old behavior of assigning the list of tools directly to bom.metadata.tools
137+
warn(
138+
'`bom.metadata.tools` as a list of Tool objects is deprecated from CycloneDX v1.5 '
139+
'onwards. Please use lists of `Component` and `Service` objects as `tools.components` '
140+
'and `tools.services`, respecitvely'
141+
)
142+
self._tools = ToolRepository(tools=tools) # type:ignore
134143

135144
@property
136145
@serializable.xml_array(serializable.XmlArraySerializationType.NESTED, 'author')

cyclonedx/model/tool.py

Lines changed: 232 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,232 @@
1+
from typing import Any, Dict, Iterable, List, Optional, Type, Union
2+
from xml.etree.ElementTree import Element # nosec B405
3+
4+
import serializable
5+
from serializable import ViewType
6+
from serializable.helpers import BaseHelper
7+
from sortedcontainers import SortedSet
8+
9+
from .._internal.compare import ComparableTuple as _ComparableTuple
10+
from ..exception.serialization import CycloneDxDeserializationException, SerializationOfUnexpectedValueException
11+
from ..model import ExternalReference, HashType, _HashTypeRepositorySerializationHelper
12+
from ..model.component import Component
13+
from ..model.service import Service
14+
from ..schema.schema import SchemaVersion1Dot4, SchemaVersion1Dot5, SchemaVersion1Dot6
15+
16+
17+
@serializable.serializable_class
18+
class Tool:
19+
"""
20+
This is our internal representation of the `toolType` complex type within the CycloneDX standard.
21+
22+
Tool(s) are the things used in the creation of the CycloneDX document.
23+
24+
Tool might be deprecated since CycloneDX 1.5, but it is not deprecated in this library.
25+
In fact, this library will try to provide a compatibility layer if needed.
26+
27+
.. note::
28+
See the CycloneDX Schema for toolType: https://cyclonedx.org/docs/1.3/#type_toolType
29+
"""
30+
31+
def __init__(self, *, vendor: Optional[str] = None, name: Optional[str] = None, version: Optional[str] = None,
32+
hashes: Optional[Iterable[HashType]] = None,
33+
external_references: Optional[Iterable[ExternalReference]] = None) -> None:
34+
self.vendor = vendor
35+
self.name = name
36+
self.version = version
37+
self.hashes = hashes or [] # type:ignore[assignment]
38+
self.external_references = external_references or [] # type:ignore[assignment]
39+
40+
@property
41+
@serializable.xml_sequence(1)
42+
def vendor(self) -> Optional[str]:
43+
"""
44+
The name of the vendor who created the tool.
45+
46+
Returns:
47+
`str` if set else `None`
48+
"""
49+
return self._vendor
50+
51+
@vendor.setter
52+
def vendor(self, vendor: Optional[str]) -> None:
53+
self._vendor = vendor
54+
55+
@property
56+
@serializable.xml_sequence(2)
57+
def name(self) -> Optional[str]:
58+
"""
59+
The name of the tool.
60+
61+
Returns:
62+
`str` if set else `None`
63+
"""
64+
return self._name
65+
66+
@name.setter
67+
def name(self, name: Optional[str]) -> None:
68+
self._name = name
69+
70+
@property
71+
@serializable.xml_sequence(3)
72+
def version(self) -> Optional[str]:
73+
"""
74+
The version of the tool.
75+
76+
Returns:
77+
`str` if set else `None`
78+
"""
79+
return self._version
80+
81+
@version.setter
82+
def version(self, version: Optional[str]) -> None:
83+
self._version = version
84+
85+
@property
86+
@serializable.type_mapping(_HashTypeRepositorySerializationHelper)
87+
@serializable.xml_sequence(4)
88+
def hashes(self) -> 'SortedSet[HashType]':
89+
"""
90+
The hashes of the tool (if applicable).
91+
92+
Returns:
93+
Set of `HashType`
94+
"""
95+
return self._hashes
96+
97+
@hashes.setter
98+
def hashes(self, hashes: Iterable[HashType]) -> None:
99+
self._hashes = SortedSet(hashes)
100+
101+
@property
102+
@serializable.view(SchemaVersion1Dot4)
103+
@serializable.view(SchemaVersion1Dot5)
104+
@serializable.view(SchemaVersion1Dot6)
105+
@serializable.xml_array(serializable.XmlArraySerializationType.NESTED, 'reference')
106+
@serializable.xml_sequence(5)
107+
def external_references(self) -> 'SortedSet[ExternalReference]':
108+
"""
109+
External References provides a way to document systems, sites, and information that may be relevant but which
110+
are not included with the BOM.
111+
112+
Returns:
113+
Set of `ExternalReference`
114+
"""
115+
return self._external_references
116+
117+
@external_references.setter
118+
def external_references(self, external_references: Iterable[ExternalReference]) -> None:
119+
self._external_references = SortedSet(external_references)
120+
121+
def __eq__(self, other: object) -> bool:
122+
if isinstance(other, Tool):
123+
return hash(other) == hash(self)
124+
return False
125+
126+
def __lt__(self, other: Any) -> bool:
127+
if isinstance(other, Tool):
128+
return _ComparableTuple((
129+
self.vendor, self.name, self.version
130+
)) < _ComparableTuple((
131+
other.vendor, other.name, other.version
132+
))
133+
return NotImplemented
134+
135+
def __hash__(self) -> int:
136+
return hash((self.vendor, self.name, self.version, tuple(self.hashes), tuple(self.external_references)))
137+
138+
def __repr__(self) -> str:
139+
return f'<Tool name={self.name}, version={self.version}, vendor={self.vendor}>'
140+
141+
142+
class ToolRepository:
143+
"""
144+
The repo of tool formats
145+
"""
146+
147+
def __init__(self, *, components: Optional[Iterable[Component]] = None,
148+
services: Optional[Iterable[Service]] = None,
149+
tools: Optional[Iterable[Tool]] = None) -> None:
150+
self._components = components or SortedSet()
151+
self._services = services or SortedSet()
152+
# Must use components/services or tools. Cannot use both
153+
self._tools = tools or SortedSet()
154+
155+
def __getattr__(self, name: str) -> Any:
156+
if name == 'components':
157+
return self._components
158+
if name == 'services':
159+
return self._services
160+
161+
return getattr(self._tools, name)
162+
163+
def __iter__(self) -> Tool:
164+
for t in self._tools:
165+
yield t
166+
167+
168+
class ToolRepositoryHelper(BaseHelper):
169+
@classmethod
170+
def json_normalize(cls, o: ToolRepository, *,
171+
view: Optional[Type[ViewType]],
172+
**__: Any) -> Any:
173+
if not any([o._tools, o._components, o._services]): # pylint: disable=protected-access
174+
return None
175+
176+
if o._tools and any([o._components, o._services]): # pylint: disable=protected-access
177+
raise SerializationOfUnexpectedValueException(
178+
'Cannot serialize both old (CycloneDX <= 1.4) and new '
179+
'(CycloneDX >= 1.5) format for tools: {o!r}'
180+
)
181+
182+
if o._tools: # pylint: disable=protected-access
183+
return [Tool.as_json(t) for t in o._tools] # pylint: disable=protected-access
184+
185+
result = {}
186+
187+
if o._components: # pylint: disable=protected-access
188+
result['components'] = [Component.as_json(c) for c in o._components] # pylint: disable=protected-access
189+
190+
if o._services: # pylint: disable=protected-access
191+
result['services'] = [Service.as_json(s) for s in o._services] # pylint: disable=protected-access
192+
193+
return result
194+
195+
@classmethod
196+
def json_denormalize(cls, o: Union[List[Dict[str, Any]], Dict[str, Any]],
197+
**__: Any) -> ToolRepository:
198+
199+
components = []
200+
services = []
201+
tools = []
202+
203+
if isinstance(o, Dict):
204+
if 'components' in o:
205+
for c in o['components']:
206+
components.append(Component.from_json(c))
207+
208+
if 'services' in o:
209+
for s in o['services']:
210+
services.append(Service.from_json(s))
211+
212+
elif isinstance(o, Iterable):
213+
for t in o:
214+
tools.append(Tool.from_json(t))
215+
else:
216+
raise CycloneDxDeserializationException('unexpected: {o!r}')
217+
218+
return ToolRepository(components=components, services=services, tools=tools)
219+
220+
@classmethod
221+
def xml_normalize(cls, o: ToolRepository, *,
222+
element_name: str,
223+
view: Optional[Type[ViewType]],
224+
xmlns: Optional[str],
225+
**__: Any) -> Optional[Element]:
226+
pass
227+
228+
@classmethod
229+
def xml_denormalize(cls, o: Element,
230+
default_ns: Optional[str],
231+
**__: Any) -> ToolRepository:
232+
return ToolRepository()

0 commit comments

Comments
 (0)