Skip to content

Commit 72fb108

Browse files
committed
rebase to main
Signed-off-by: Johannes Feichtner <johannes@web-wack.at>
1 parent 4ef5bc3 commit 72fb108

17 files changed

Lines changed: 416 additions & 4 deletions

cyclonedx/model/license.py

Lines changed: 181 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
from .._internal.compare import ComparableTuple as _ComparableTuple
3535
from ..exception.model import MutuallyExclusivePropertiesException
3636
from ..exception.serialization import CycloneDxDeserializationException
37+
from ..schema import SchemaVersion
3738
from ..schema.schema import SchemaVersion1Dot5, SchemaVersion1Dot6, SchemaVersion1Dot7
3839
from . import AttachedText, Property, XsUri
3940
from .bom_ref import BomRef
@@ -278,6 +279,122 @@ def __repr__(self) -> str:
278279
return f'<License id={self._id!r}, name={self._name!r}>'
279280

280281

282+
@serializable.serializable_class(ignore_unknown_during_deserialization=True)
283+
class ExpressionDetails:
284+
"""
285+
This is our internal representation of the ``licenseExpressionDetailedType`` complex type that specifies the details
286+
and attributes related to a software license identifier within a CycloneDX BOM document.
287+
288+
.. note::
289+
Introduced in CycloneDX v1.7
290+
291+
292+
.. note::
293+
See the CycloneDX Schema definition: https://cyclonedx.org/docs/1.7/xml/#type_licenseExpressionDetailedType
294+
"""
295+
296+
def __init__(
297+
self, license_identifier: str, *,
298+
bom_ref: Optional[Union[str, BomRef]] = None,
299+
text: Optional[AttachedText] = None,
300+
url: Optional[XsUri] = None,
301+
) -> None:
302+
self._bom_ref = _bom_ref_from_str(bom_ref)
303+
self.license_identifier = license_identifier
304+
self.text = text
305+
self.url = url
306+
307+
@property
308+
@serializable.xml_name('license-identifier')
309+
@serializable.xml_string(serializable.XmlStringSerializationType.NORMALIZED_STRING)
310+
@serializable.xml_attribute()
311+
def license_identifier(self) -> str:
312+
"""
313+
A valid SPDX license identifier. Refer to https://spdx.org/specifications for syntax requirements.
314+
This field serves as the primary key, which uniquely identifies each record.
315+
316+
Example values:
317+
- "Apache-2.0",
318+
- "GPL-3.0-only WITH Classpath-exception-2.0"
319+
- "LicenseRef-my-custom-license"
320+
321+
Returns:
322+
`str`
323+
"""
324+
return self._license_identifier
325+
326+
@license_identifier.setter
327+
def license_identifier(self, license_identifier: str) -> None:
328+
self._license_identifier = license_identifier
329+
330+
@property
331+
@serializable.json_name('bom-ref')
332+
@serializable.type_mapping(BomRef)
333+
@serializable.xml_attribute()
334+
@serializable.xml_name('bom-ref')
335+
def bom_ref(self) -> BomRef:
336+
"""
337+
An optional identifier which can be used to reference the component elsewhere in the BOM. Every bom-ref MUST be
338+
unique within the BOM.
339+
340+
Returns:
341+
`BomRef`
342+
"""
343+
return self._bom_ref
344+
345+
@property
346+
@serializable.xml_sequence(1)
347+
def text(self) -> Optional[AttachedText]:
348+
"""
349+
Specifies the optional full text of the attachment
350+
351+
Returns:
352+
`AttachedText` else `None`
353+
"""
354+
return self._text
355+
356+
@text.setter
357+
def text(self, text: Optional[AttachedText]) -> None:
358+
self._text = text
359+
360+
@property
361+
@serializable.xml_sequence(2)
362+
def url(self) -> Optional[XsUri]:
363+
"""
364+
The URL to the attachment file. If the attachment is a license or BOM, an externalReference should also be
365+
specified for completeness.
366+
367+
Returns:
368+
`XsUri` or `None`
369+
"""
370+
return self._url
371+
372+
@url.setter
373+
def url(self, url: Optional[XsUri]) -> None:
374+
self._url = url
375+
376+
def __comparable_tuple(self) -> _ComparableTuple:
377+
return _ComparableTuple((
378+
self.bom_ref.value, self.license_identifier, self.url, self.text,
379+
))
380+
381+
def __eq__(self, other: object) -> bool:
382+
if isinstance(other, ExpressionDetails):
383+
return self.__comparable_tuple() == other.__comparable_tuple()
384+
return False
385+
386+
def __lt__(self, other: object) -> bool:
387+
if isinstance(other, ExpressionDetails):
388+
return self.__comparable_tuple() < other.__comparable_tuple()
389+
return NotImplemented
390+
391+
def __hash__(self) -> int:
392+
return hash(self.__comparable_tuple())
393+
394+
def __repr__(self) -> str:
395+
return f'<ExpressionDetails bom-ref={self.bom_ref!r}, license_identifier={self.license_identifier}>'
396+
397+
281398
@serializable.serializable_class(
282399
name='expression',
283400
ignore_unknown_during_deserialization=True
@@ -296,10 +413,12 @@ def __init__(
296413
self, value: str, *,
297414
bom_ref: Optional[Union[str, BomRef]] = None,
298415
acknowledgement: Optional[LicenseAcknowledgement] = None,
416+
details: Optional[Iterable[ExpressionDetails]] = None,
299417
) -> None:
300418
self._bom_ref = _bom_ref_from_str(bom_ref)
301419
self._value = value
302420
self._acknowledgement = acknowledgement
421+
self.details = details or []
303422

304423
@property
305424
@serializable.view(SchemaVersion1Dot5)
@@ -362,11 +481,30 @@ def acknowledgement(self) -> Optional[LicenseAcknowledgement]:
362481
def acknowledgement(self, acknowledgement: Optional[LicenseAcknowledgement]) -> None:
363482
self._acknowledgement = acknowledgement
364483

484+
@property
485+
@serializable.json_name('expressionDetails')
486+
@serializable.view(SchemaVersion1Dot7)
487+
@serializable.xml_array(serializable.XmlArraySerializationType.FLAT, child_name='details')
488+
@serializable.xml_sequence(1)
489+
def details(self) -> 'SortedSet[ExpressionDetails]':
490+
"""
491+
Details for parts of the expression.
492+
493+
Returns:
494+
Set of `ExpressionDetails`
495+
"""
496+
return self._details
497+
498+
@details.setter
499+
def details(self, details: Iterable[ExpressionDetails]) -> None:
500+
self._details = SortedSet(details)
501+
365502
def __comparable_tuple(self) -> _ComparableTuple:
366503
return _ComparableTuple((
367504
self._acknowledgement,
368505
self._value,
369506
self._bom_ref.value,
507+
_ComparableTuple(self.details),
370508
))
371509

372510
def __hash__(self) -> int:
@@ -431,6 +569,40 @@ class LicenseRepository(SortedSet):
431569
class _LicenseRepositorySerializationHelper(serializable.helpers.BaseHelper):
432570
""" THIS CLASS IS NON-PUBLIC API """
433571

572+
@staticmethod
573+
def __supports_expression_details(view: Any) -> bool:
574+
try:
575+
return view is not None and view().schema_version_enum >= SchemaVersion.V1_7
576+
except Exception: # pragma: no cover
577+
return False
578+
579+
@staticmethod
580+
def __serialize_license_expression_details_xml(
581+
license_expression: LicenseExpression,
582+
view: Optional[type[serializable.ViewType]],
583+
xmlns: Optional[str]
584+
) -> Element:
585+
elem: Element = license_expression.as_xml( # type:ignore[attr-defined]
586+
view_=view, as_string=False, element_name='expression-detailed', xmlns=xmlns)
587+
expression_value = elem.text
588+
if expression_value:
589+
elem.set(f'{{{xmlns}}}expression' if xmlns else 'expression', expression_value)
590+
elem.text = None
591+
return elem
592+
593+
@staticmethod
594+
def __deserialize_license_expression_details_xml(
595+
li: Element,
596+
default_ns: Optional[str]
597+
) -> LicenseExpression:
598+
expression_value = li.get('expression')
599+
if not expression_value:
600+
raise CycloneDxDeserializationException(f'unexpected content: {li!r}')
601+
license_expression: LicenseExpression = LicenseExpression.from_xml( # type:ignore[attr-defined]
602+
li, default_ns)
603+
license_expression.value = expression_value
604+
return license_expression
605+
434606
@classmethod
435607
def json_normalize(cls, o: LicenseRepository, *,
436608
view: Optional[type[serializable.ViewType]],
@@ -482,8 +654,13 @@ def xml_normalize(cls, o: LicenseRepository, *,
482654
# mixed license expression and license? this is an invalid constellation according to schema!
483655
# see https://github.com/CycloneDX/specification/pull/205
484656
# but models need to allow it for backwards compatibility with JSON CDX < 1.5
485-
elem.append(expression.as_xml( # type:ignore[attr-defined]
486-
view_=view, as_string=False, element_name='expression', xmlns=xmlns))
657+
if expression.details and cls.__supports_expression_details(view):
658+
elem.append(cls.__serialize_license_expression_details_xml(expression, view, xmlns))
659+
else:
660+
if expression.details:
661+
warn('LicenseExpression details are not supported in schema versions < 1.7; skipping serialization')
662+
elem.append(expression.as_xml( # type:ignore[attr-defined]
663+
view_=view, as_string=False, element_name='expression', xmlns=xmlns))
487664
else:
488665
elem.extend(
489666
li.as_xml( # type:ignore[attr-defined]
@@ -506,6 +683,8 @@ def xml_denormalize(cls, o: Element,
506683
elif tag == 'expression':
507684
repo.add(LicenseExpression.from_xml( # type:ignore[attr-defined]
508685
li, default_ns))
686+
elif tag == 'expression-detailed':
687+
repo.add(cls.__deserialize_license_expression_details_xml(li, default_ns))
509688
else:
510689
raise CycloneDxDeserializationException(f'unexpected: {li!r}')
511690
return repo

tests/_data/models.py

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,13 @@
9797
ImpactAnalysisState,
9898
)
9999
from cyclonedx.model.issue import IssueClassification, IssueType, IssueTypeSource
100-
from cyclonedx.model.license import DisjunctiveLicense, License, LicenseAcknowledgement, LicenseExpression
100+
from cyclonedx.model.license import (
101+
DisjunctiveLicense,
102+
ExpressionDetails,
103+
License,
104+
LicenseAcknowledgement,
105+
LicenseExpression,
106+
)
101107
from cyclonedx.model.lifecycle import LifecyclePhase, NamedLifecycle, PredefinedLifecycle
102108
from cyclonedx.model.release_note import ReleaseNotes
103109
from cyclonedx.model.service import Service
@@ -1061,6 +1067,15 @@ def get_vulnerability_source_owasp() -> VulnerabilitySource:
10611067

10621068

10631069
def get_bom_with_licenses() -> Bom:
1070+
expression_details = [
1071+
ExpressionDetails(license_identifier='GPL-3.0-or-later',
1072+
url=XsUri('https://www.apache.org/licenses/LICENSE-2.0.txt'),
1073+
text=AttachedText(content='specific GPL-3.0-or-later license text')),
1074+
ExpressionDetails(license_identifier='GPL-2.0',
1075+
bom_ref='some-bomref-1234',
1076+
text=AttachedText(content='specific GPL-2.0 license text')),
1077+
]
1078+
10641079
return _make_bom(
10651080
metadata=BomMetaData(
10661081
licenses=[DisjunctiveLicense(id='CC-BY-1.0')],
@@ -1090,6 +1105,11 @@ def get_bom_with_licenses() -> Bom:
10901105
DisjunctiveLicense(name='some other license',
10911106
properties=[Property(name='myname', value='proprietary')]),
10921107
]),
1108+
Component(name='c-with-expression-details', type=ComponentType.LIBRARY, bom_ref='C5',
1109+
licenses=[LicenseExpression(value='GPL-3.0-or-later OR GPL-2.0',
1110+
details=expression_details,
1111+
acknowledgement=LicenseAcknowledgement.DECLARED
1112+
)]),
10931113
],
10941114
services=[
10951115
Service(name='s-with-expression', bom_ref='S1',

tests/_data/snapshots/get_bom_with_licenses-1.0.xml.bin

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,11 @@
1111
<version/>
1212
<modified>false</modified>
1313
</component>
14+
<component type="library">
15+
<name>c-with-expression-details</name>
16+
<version/>
17+
<modified>false</modified>
18+
</component>
1419
<component type="library">
1520
<name>c-with-license-properties</name>
1621
<version/>

tests/_data/snapshots/get_bom_with_licenses-1.1.xml.bin

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,13 @@
1818
<expression>Apache-2.0 OR MIT</expression>
1919
</licenses>
2020
</component>
21+
<component type="library" bom-ref="C5">
22+
<name>c-with-expression-details</name>
23+
<version/>
24+
<licenses>
25+
<expression>GPL-3.0-or-later OR GPL-2.0</expression>
26+
</licenses>
27+
</component>
2128
<component type="library" bom-ref="C4">
2229
<name>c-with-license-properties</name>
2330
<version/>

tests/_data/snapshots/get_bom_with_licenses-1.2.json.bin

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,17 @@
2525
"type": "library",
2626
"version": ""
2727
},
28+
{
29+
"bom-ref": "C5",
30+
"licenses": [
31+
{
32+
"expression": "GPL-3.0-or-later OR GPL-2.0"
33+
}
34+
],
35+
"name": "c-with-expression-details",
36+
"type": "library",
37+
"version": ""
38+
},
2839
{
2940
"bom-ref": "C4",
3041
"licenses": [
@@ -83,6 +94,9 @@
8394
{
8495
"ref": "C4"
8596
},
97+
{
98+
"ref": "C5"
99+
},
86100
{
87101
"ref": "S1"
88102
},

tests/_data/snapshots/get_bom_with_licenses-1.2.xml.bin

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,13 @@
3030
<expression>Apache-2.0 OR MIT</expression>
3131
</licenses>
3232
</component>
33+
<component type="library" bom-ref="C5">
34+
<name>c-with-expression-details</name>
35+
<version/>
36+
<licenses>
37+
<expression>GPL-3.0-or-later OR GPL-2.0</expression>
38+
</licenses>
39+
</component>
3340
<component type="library" bom-ref="C4">
3441
<name>c-with-license-properties</name>
3542
<version/>
@@ -92,6 +99,7 @@
9299
<dependency ref="C2"/>
93100
<dependency ref="C3"/>
94101
<dependency ref="C4"/>
102+
<dependency ref="C5"/>
95103
<dependency ref="S1"/>
96104
<dependency ref="S2"/>
97105
<dependency ref="S3"/>

tests/_data/snapshots/get_bom_with_licenses-1.3.json.bin

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,17 @@
2525
"type": "library",
2626
"version": ""
2727
},
28+
{
29+
"bom-ref": "C5",
30+
"licenses": [
31+
{
32+
"expression": "GPL-3.0-or-later OR GPL-2.0"
33+
}
34+
],
35+
"name": "c-with-expression-details",
36+
"type": "library",
37+
"version": ""
38+
},
2839
{
2940
"bom-ref": "C4",
3041
"licenses": [
@@ -83,6 +94,9 @@
8394
{
8495
"ref": "C4"
8596
},
97+
{
98+
"ref": "C5"
99+
},
86100
{
87101
"ref": "S1"
88102
},

0 commit comments

Comments
 (0)