Skip to content

Commit fe62f47

Browse files
committed
feat: add support for lifecycles in BOM metadata
1 parent 2b952e9 commit fe62f47

21 files changed

+913
-12
lines changed

cyclonedx/model/bom.py

Lines changed: 21 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
from .contact import OrganizationalContact, OrganizationalEntity
4444
from .dependency import Dependable, Dependency
4545
from .license import License, LicenseExpression, LicenseRepository
46+
from .lifecycle import Lifecycle, LifecycleRepository, _LifecycleRepositoryHelper
4647
from .service import Service
4748
from .vulnerability import Vulnerability
4849

@@ -69,6 +70,7 @@ def __init__(
6970
properties: Optional[Iterable[Property]] = None,
7071
timestamp: Optional[datetime] = None,
7172
manufacturer: Optional[OrganizationalEntity] = None,
73+
lifecycles: Optional[Iterable[Lifecycle]] = None,
7274
# Deprecated as of v1.6
7375
manufacture: Optional[OrganizationalEntity] = None,
7476
) -> None:
@@ -80,6 +82,7 @@ def __init__(
8082
self.licenses = licenses or [] # type:ignore[assignment]
8183
self.properties = properties or [] # type:ignore[assignment]
8284
self.manufacturer = manufacturer
85+
self.lifecycles = lifecycles or [] # type:ignore[assignment]
8386

8487
self.manufacture = manufacture
8588
if manufacture:
@@ -107,16 +110,23 @@ def timestamp(self) -> datetime:
107110
def timestamp(self, timestamp: datetime) -> None:
108111
self._timestamp = timestamp
109112

110-
# @property
111-
# ...
112-
# @serializable.view(SchemaVersion1Dot5)
113-
# @serializable.xml_sequence(2)
114-
# def lifecycles(self) -> ...:
115-
# ... # TODO since CDX1.5
116-
#
117-
# @lifecycles.setter
118-
# def lifecycles(self, ...) -> None:
119-
# ... # TODO since CDX1.5
113+
@property
114+
@serializable.view(SchemaVersion1Dot5)
115+
@serializable.view(SchemaVersion1Dot6)
116+
@serializable.type_mapping(_LifecycleRepositoryHelper)
117+
@serializable.xml_sequence(2)
118+
def lifecycles(self) -> LifecycleRepository:
119+
"""
120+
An optional list of BOM lifecycle stages.
121+
122+
Returns:
123+
Set of `Lifecycle`
124+
"""
125+
return self._lifecycles
126+
127+
@lifecycles.setter
128+
def lifecycles(self, lifecycles: Iterable[Lifecycle]) -> None:
129+
self._lifecycles = LifecycleRepository(lifecycles)
120130

121131
@property
122132
@serializable.xml_array(serializable.XmlArraySerializationType.NESTED, 'tool')
@@ -292,7 +302,7 @@ def __eq__(self, other: object) -> bool:
292302
def __hash__(self) -> int:
293303
return hash((
294304
tuple(self.authors), self.component, tuple(self.licenses), self.manufacture, tuple(self.properties),
295-
self.supplier, self.timestamp, tuple(self.tools), self.manufacturer,
305+
self.supplier, self.timestamp, tuple(self.tools), tuple(self.lifecycles), self.manufacturer
296306
))
297307

298308
def __repr__(self) -> str:

cyclonedx/model/lifecycle.py

Lines changed: 240 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,240 @@
1+
# This file is part of CycloneDX Python Library
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
#
15+
# SPDX-License-Identifier: Apache-2.0
16+
# Copyright (c) OWASP Foundation. All Rights Reserved.
17+
18+
"""
19+
This set of classes represents the lifecycles types in the CycloneDX standard.
20+
21+
.. note::
22+
Introduced in CycloneDX v1.5
23+
24+
.. note::
25+
See the CycloneDX Schema for lifecycles: https://cyclonedx.org/docs/1.5/#metadata_lifecycles
26+
"""
27+
28+
from enum import Enum
29+
from json import loads as json_loads
30+
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Type, Union
31+
from xml.etree.ElementTree import Element # nosec B405
32+
33+
import serializable
34+
from serializable.helpers import BaseHelper
35+
from sortedcontainers import SortedSet
36+
37+
from .._internal.compare import ComparableTuple as _ComparableTuple
38+
from ..exception.serialization import CycloneDxDeserializationException
39+
40+
if TYPE_CHECKING: # pragma: no cover
41+
from serializable import ViewType
42+
43+
44+
@serializable.serializable_enum
45+
class Phase(str, Enum):
46+
DESIGN = 'design'
47+
PREBUILD = 'pre-build'
48+
BUILD = 'build'
49+
POSTBUILD = 'post-build'
50+
OPERATIONS = 'operations'
51+
DISCOVERY = 'discovery'
52+
DECOMISSION = 'decommission'
53+
54+
55+
@serializable.serializable_class
56+
class PredefinedPhase:
57+
"""
58+
Object that defines pre-defined phases in the product lifecycle.
59+
60+
.. note::
61+
See the CycloneDX Schema definition: https://cyclonedx.org/docs/1.5/#metadata_lifecycles
62+
"""
63+
64+
def __init__(self, phase: Phase) -> None:
65+
self._phase = phase
66+
67+
@property
68+
def phase(self) -> Phase:
69+
return self._phase
70+
71+
@phase.setter
72+
def phase(self, phase: Phase) -> None:
73+
self._phase = phase
74+
75+
def __hash__(self) -> int:
76+
return hash(self._phase)
77+
78+
def __eq__(self, other: object) -> bool:
79+
if isinstance(other, PredefinedPhase):
80+
return hash(other) == hash(self)
81+
return False
82+
83+
def __lt__(self, other: Any) -> bool:
84+
if isinstance(other, PredefinedPhase):
85+
return self._phase < other._phase
86+
if isinstance(other, CustomPhase):
87+
return True # put PredefinedPhase before any CustomPhase
88+
return NotImplemented
89+
90+
def __repr__(self) -> str:
91+
return f'<PredefinedPhase name={self._phase}>'
92+
93+
94+
@serializable.serializable_class
95+
class CustomPhase:
96+
def __init__(self, name: str, description: Optional[str] = None) -> None:
97+
self._name = name
98+
self._description = description
99+
100+
@property
101+
@serializable.xml_sequence(1)
102+
@serializable.xml_string(serializable.XmlStringSerializationType.NORMALIZED_STRING)
103+
def name(self) -> str:
104+
"""
105+
Name of the lifecycle phase.
106+
107+
Returns:
108+
`str`
109+
"""
110+
return self._name
111+
112+
@name.setter
113+
def name(self, name: str) -> None:
114+
self._name = name
115+
116+
@property
117+
@serializable.xml_sequence(2)
118+
@serializable.xml_string(serializable.XmlStringSerializationType.NORMALIZED_STRING)
119+
def description(self) -> Optional[str]:
120+
"""
121+
Description of the lifecycle phase.
122+
123+
Returns:
124+
`str`
125+
"""
126+
return self._description
127+
128+
@description.setter
129+
def description(self, description: Optional[str]) -> None:
130+
self._description = description
131+
132+
def __hash__(self) -> int:
133+
return hash((self._name, self._description))
134+
135+
def __eq__(self, other: object) -> bool:
136+
if isinstance(other, CustomPhase):
137+
return hash(other) == hash(self)
138+
return False
139+
140+
def __lt__(self, other: Any) -> bool:
141+
if isinstance(other, CustomPhase):
142+
return _ComparableTuple((self._name, self._description)) < _ComparableTuple(
143+
(other._name, other._description)
144+
)
145+
if isinstance(other, PredefinedPhase):
146+
return False # put CustomPhase after any PredefinedPhase
147+
return NotImplemented
148+
149+
def __repr__(self) -> str:
150+
return f'<CustomPhase name={self._name}>'
151+
152+
153+
Lifecycle = Union[PredefinedPhase, CustomPhase]
154+
"""TypeAlias for a union of supported lifecycle models.
155+
156+
- :class:`PredefinedPhase`
157+
- :class:`CustomPhase`
158+
"""
159+
160+
if TYPE_CHECKING: # pragma: no cover
161+
# workaround for https://github.com/python/mypy/issues/5264
162+
# this code path is taken when static code analysis or documentation tools runs through.
163+
class LifecycleRepository(SortedSet[Lifecycle]):
164+
"""Collection of :class:`Lifecycle`.
165+
166+
This is a `set`, not a `list`. Order MUST NOT matter here.
167+
"""
168+
169+
else:
170+
171+
class LifecycleRepository(SortedSet):
172+
"""Collection of :class:`Lifecycle`.
173+
174+
This is a `set`, not a `list`. Order MUST NOT matter here.
175+
"""
176+
177+
178+
class _LifecycleRepositoryHelper(BaseHelper):
179+
@classmethod
180+
def json_normalize(cls, o: LifecycleRepository, *,
181+
view: Optional[Type['ViewType']],
182+
**__: Any) -> Any:
183+
if len(o) == 0:
184+
return None
185+
186+
return [json_loads(li.as_json( # type:ignore[union-attr]
187+
view_=view)) for li in o]
188+
189+
@classmethod
190+
def json_denormalize(cls, o: List[Dict[str, Any]],
191+
**__: Any) -> LifecycleRepository:
192+
repo = LifecycleRepository()
193+
for li in o:
194+
if 'phase' in li:
195+
repo.add(PredefinedPhase.from_json(li)) # type:ignore[attr-defined]
196+
elif 'name' in li:
197+
repo.add(CustomPhase.from_json(li)) # type:ignore[attr-defined]
198+
else:
199+
raise CycloneDxDeserializationException(f'unexpected: {li!r}')
200+
201+
return repo
202+
203+
@classmethod
204+
def xml_normalize(cls, o: LifecycleRepository, *,
205+
element_name: str,
206+
view: Optional[Type['ViewType']],
207+
xmlns: Optional[str],
208+
**__: Any) -> Optional[Element]:
209+
if len(o) == 0:
210+
return None
211+
212+
elem = Element(element_name)
213+
for li in o:
214+
elem.append(li.as_xml( # type:ignore[union-attr]
215+
view_=view, as_string=False, element_name='lifecycle', xmlns=xmlns))
216+
217+
return elem
218+
219+
@classmethod
220+
def xml_denormalize(cls, o: Element,
221+
default_ns: Optional[str],
222+
**__: Any) -> LifecycleRepository:
223+
repo = LifecycleRepository()
224+
225+
for li in o:
226+
tag = li.tag if default_ns is None else li.tag.replace(f'{{{default_ns}}}', '')
227+
228+
if tag == 'lifecycle':
229+
stages = list(li)
230+
231+
predefined_phase = next((el for el in stages if 'phase' in el.tag), None)
232+
custom_phase = next((el for el in stages if 'name' in el.tag), None)
233+
if predefined_phase is not None:
234+
repo.add(PredefinedPhase.from_xml(li, default_ns)) # type:ignore[attr-defined]
235+
elif custom_phase is not None:
236+
repo.add(CustomPhase.from_xml(li, default_ns)) # type:ignore[attr-defined]
237+
else:
238+
raise CycloneDxDeserializationException(f'unexpected: {li!r}')
239+
240+
return repo

tests/_data/models.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@
8787
)
8888
from cyclonedx.model.issue import IssueClassification, IssueType, IssueTypeSource
8989
from cyclonedx.model.license import DisjunctiveLicense, License, LicenseAcknowledgement, LicenseExpression
90+
from cyclonedx.model.lifecycle import CustomPhase, Phase, PredefinedPhase
9091
from cyclonedx.model.release_note import ReleaseNotes
9192
from cyclonedx.model.service import Service
9293
from cyclonedx.model.vulnerability import (
@@ -533,6 +534,7 @@ def get_bom_just_complete_metadata() -> Bom:
533534
content='VGVzdCBjb250ZW50IC0gdGhpcyBpcyBub3QgdGhlIEFwYWNoZSAyLjAgbGljZW5zZSE='
534535
)
535536
)]
537+
bom.metadata.lifecycles = [PredefinedPhase(Phase.BUILD)]
536538
bom.metadata.properties = get_properties_1()
537539
return bom
538540

@@ -1122,6 +1124,20 @@ def get_bom_for_issue_630_empty_property() -> Bom:
11221124
)
11231125
})
11241126

1127+
1128+
def get_bom_with_lifecycles() -> Bom:
1129+
return _make_bom(
1130+
metadata=BomMetaData(
1131+
lifecycles=[
1132+
PredefinedPhase(Phase.BUILD),
1133+
PredefinedPhase(Phase.POSTBUILD),
1134+
CustomPhase(name='platform-integration-testing',
1135+
description='Integration testing specific to the runtime platform'),
1136+
],
1137+
component=Component(name='app', type=ComponentType.APPLICATION, bom_ref='my-app'),
1138+
),
1139+
)
1140+
11251141
# ---
11261142

11271143

@@ -1162,4 +1178,5 @@ def get_bom_for_issue_630_empty_property() -> Bom:
11621178
get_bom_for_issue_598_multiple_components_with_purl_qualifiers,
11631179
get_bom_with_component_setuptools_with_v16_fields,
11641180
get_bom_for_issue_630_empty_property,
1181+
get_bom_with_lifecycles,
11651182
}

tests/_data/snapshots/get_bom_just_complete_metadata-1.5.json.bin

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -350,6 +350,11 @@
350350
}
351351
}
352352
],
353+
"lifecycles": [
354+
{
355+
"phase": "build"
356+
}
357+
],
353358
"manufacture": {
354359
"contact": [
355360
{

tests/_data/snapshots/get_bom_just_complete_metadata-1.5.xml.bin

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,11 @@
22
<bom xmlns="http://cyclonedx.org/schema/bom/1.5" serialNumber="urn:uuid:1441d33a-e0fc-45b5-af3b-61ee52a88bac" version="1">
33
<metadata>
44
<timestamp>2023-01-07T13:44:32.312678+00:00</timestamp>
5+
<lifecycles>
6+
<lifecycle>
7+
<phase>build</phase>
8+
</lifecycle>
9+
</lifecycles>
510
<tools>
611
<tool>
712
<vendor>CycloneDX</vendor>

tests/_data/snapshots/get_bom_just_complete_metadata-1.6.json.bin

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -380,6 +380,11 @@
380380
}
381381
}
382382
],
383+
"lifecycles": [
384+
{
385+
"phase": "build"
386+
}
387+
],
383388
"manufacture": {
384389
"address": {
385390
"country": "GB",

tests/_data/snapshots/get_bom_just_complete_metadata-1.6.xml.bin

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,11 @@
22
<bom xmlns="http://cyclonedx.org/schema/bom/1.6" serialNumber="urn:uuid:1441d33a-e0fc-45b5-af3b-61ee52a88bac" version="1">
33
<metadata>
44
<timestamp>2023-01-07T13:44:32.312678+00:00</timestamp>
5+
<lifecycles>
6+
<lifecycle>
7+
<phase>build</phase>
8+
</lifecycle>
9+
</lifecycles>
510
<tools>
611
<tool>
712
<vendor>CycloneDX</vendor>

0 commit comments

Comments
 (0)