From f06071dde755d2cbae18c6847a8e6fa9be126875 Mon Sep 17 00:00:00 2001 From: Johannes Feichtner Date: Sun, 2 Nov 2025 21:35:05 +0100 Subject: [PATCH 1/5] feat: add support for license expression details Signed-off-by: Johannes Feichtner --- cyclonedx/model/license.py | 313 +++++++++++++++++- tests/_data/models.py | 17 +- .../get_bom_with_licenses-1.0.xml.bin | 5 + .../get_bom_with_licenses-1.1.xml.bin | 5 + .../get_bom_with_licenses-1.2.json.bin | 10 + .../get_bom_with_licenses-1.2.xml.bin | 6 + .../get_bom_with_licenses-1.3.json.bin | 10 + .../get_bom_with_licenses-1.3.xml.bin | 6 + .../get_bom_with_licenses-1.4.json.bin | 9 + .../get_bom_with_licenses-1.4.xml.bin | 5 + .../get_bom_with_licenses-1.5.json.bin | 9 + .../get_bom_with_licenses-1.5.xml.bin | 5 + .../get_bom_with_licenses-1.6.json.bin | 9 + .../get_bom_with_licenses-1.6.xml.bin | 5 + .../get_bom_with_licenses-1.7.json.bin | 32 ++ .../get_bom_with_licenses-1.7.xml.bin | 15 + tests/test_model_license.py | 69 +++- 17 files changed, 524 insertions(+), 6 deletions(-) diff --git a/cyclonedx/model/license.py b/cyclonedx/model/license.py index 3d936942..1ec62422 100644 --- a/cyclonedx/model/license.py +++ b/cyclonedx/model/license.py @@ -22,11 +22,12 @@ from enum import Enum from json import loads as json_loads -from typing import TYPE_CHECKING, Any, Optional, Union +from typing import TYPE_CHECKING, Any, Iterable, Optional, Union from warnings import warn from xml.etree.ElementTree import Element # nosec B405 import py_serializable as serializable +from cyclonedx.schema import SchemaVersion from sortedcontainers import SortedSet from .._internal.bom_ref import bom_ref_from_str as _bom_ref_from_str @@ -34,7 +35,7 @@ from ..exception.model import MutuallyExclusivePropertiesException from ..exception.serialization import CycloneDxDeserializationException from ..schema.schema import SchemaVersion1Dot5, SchemaVersion1Dot6, SchemaVersion1Dot7 -from . import AttachedText, XsUri +from . import AttachedText, Property, XsUri from .bom_ref import BomRef @@ -251,6 +252,8 @@ def __eq__(self, other: object) -> bool: def __lt__(self, other: Any) -> bool: if isinstance(other, DisjunctiveLicense): return self.__comparable_tuple() < other.__comparable_tuple() + if isinstance(other, LicenseExpressionDetailed): + return False # self after any LicenseExpressionDetailed if isinstance(other, LicenseExpression): return False # self after any LicenseExpression return NotImplemented @@ -364,6 +367,8 @@ def __eq__(self, other: object) -> bool: def __lt__(self, other: Any) -> bool: if isinstance(other, LicenseExpression): return self.__comparable_tuple() < other.__comparable_tuple() + if isinstance(other, LicenseExpressionDetailed): + return False # self after any LicenseExpressionDetailed if isinstance(other, DisjunctiveLicense): return True # self before any DisjunctiveLicense return NotImplemented @@ -372,10 +377,281 @@ def __repr__(self) -> str: return f'' -License = Union[LicenseExpression, DisjunctiveLicense] +@serializable.serializable_class(ignore_unknown_during_deserialization=True) +class ExpressionDetails: + """ + This is our internal representation of the `licenseExpressionDetailedType` complex type that specifies the details + and attributes related to a software license identifier within a CycloneDX BOM document. + + .. note:: + Introduced in CycloneDX v1.7 + + + .. note:: + See the CycloneDX Schema definition: https://cyclonedx.org/docs/1.7/xml/#type_licenseExpressionDetailedType + """ + + def __init__( + self, license_identifier: str, *, + bom_ref: Optional[Union[str, BomRef]] = None, + text: Optional[AttachedText] = None, + url: Optional[XsUri] = None, + ) -> None: + self._bom_ref = _bom_ref_from_str(bom_ref) + self.license_identifier = license_identifier + self.text = text + self.url = url + + @property + @serializable.xml_name('license-identifier') + @serializable.xml_string(serializable.XmlStringSerializationType.NORMALIZED_STRING) + @serializable.xml_attribute() + def license_identifier(self) -> str: + """ + A valid SPDX license identifier. Refer to https://spdx.org/specifications for syntax requirements. + This field serves as the primary key, which uniquely identifies each record. + + Example values: + - "Apache-2.0", + - "GPL-3.0-only WITH Classpath-exception-2.0" + - "LicenseRef-my-custom-license" + + Returns: + `str` + """ + return self._license_identifier + + @license_identifier.setter + def license_identifier(self, license_identifier: str) -> None: + self._license_identifier = license_identifier + + @property + @serializable.json_name('bom-ref') + @serializable.type_mapping(BomRef) + @serializable.xml_attribute() + @serializable.xml_name('bom-ref') + def bom_ref(self) -> BomRef: + """ + An optional identifier which can be used to reference the component elsewhere in the BOM. Every bom-ref MUST be + unique within the BOM. + + Returns: + `BomRef` + """ + return self._bom_ref + + @property + @serializable.xml_sequence(1) + def text(self) -> Optional[AttachedText]: + """ + Specifies the optional full text of the attachment + + Returns: + `AttachedText` else `None` + """ + return self._text + + @text.setter + def text(self, text: Optional[AttachedText]) -> None: + self._text = text + + @property + @serializable.xml_sequence(2) + def url(self) -> Optional[XsUri]: + """ + The URL to the attachment file. If the attachment is a license or BOM, an externalReference should also be + specified for completeness. + + Returns: + `XsUri` or `None` + """ + return self._url + + @url.setter + def url(self, url: Optional[XsUri]) -> None: + self._url = url + + def __comparable_tuple(self) -> _ComparableTuple: + return _ComparableTuple(( + self.bom_ref.value, self.license_identifier, self.url, self.text, + )) + + def __eq__(self, other: object) -> bool: + if isinstance(other, ExpressionDetails): + return self.__comparable_tuple() == other.__comparable_tuple() + return False + + def __lt__(self, other: object) -> bool: + if isinstance(other, ExpressionDetails): + return self.__comparable_tuple() < other.__comparable_tuple() + return NotImplemented + + def __hash__(self) -> int: + return hash(self.__comparable_tuple()) + + def __repr__(self) -> str: + return f'' + + +@serializable.serializable_class(ignore_unknown_during_deserialization=True) +class LicenseExpressionDetailed: + """ + Specifies the details and attributes related to a software license. + It must be a valid SPDX license expression, along with additional properties such as license acknowledgment. + + .. note:: + See the CycloneDX Schema definition: + https://cyclonedx.org/docs/1.7/json/#components_items_licenses_items_oneOf_i1_expressionDetails + """ + + def __init__( + self, expression: str, *, + expression_details: Optional[Iterable[ExpressionDetails]] = None, + bom_ref: Optional[Union[str, BomRef]] = None, + acknowledgement: Optional[LicenseAcknowledgement] = None, + properties: Optional[Iterable[Property]] = None, + ) -> None: + self._bom_ref = _bom_ref_from_str(bom_ref) + self.expression = expression + self.acknowledgement = acknowledgement + self.expression_details = expression_details or [] + self.properties = properties or [] + + @property + @serializable.type_mapping(BomRef) + @serializable.xml_attribute() + @serializable.xml_name('bom-ref') + @serializable.json_name('bom-ref') + def bom_ref(self) -> BomRef: + """ + An optional identifier which can be used to reference the component elsewhere in the BOM. Every bom-ref MUST be + unique within the BOM. + + Returns: + `BomRef` + """ + return self._bom_ref + + @property + @serializable.xml_string(serializable.XmlStringSerializationType.NORMALIZED_STRING) + @serializable.xml_attribute() + def expression(self) -> str: + """ + A valid SPDX license expression. + Refer to https://spdx.org/specifications for syntax requirements. + + Returns: + `str` + """ + return self._expression + + @expression.setter + def expression(self, expression: str) -> None: + self._expression = expression + + @property + @serializable.xml_attribute() + def acknowledgement(self) -> Optional[LicenseAcknowledgement]: + """ + Declared licenses and concluded licenses represent two different stages in the licensing process within + software development. + + Declared licenses refer to the initial intention of the software authors regarding the + licensing terms under which their code is released. On the other hand, concluded licenses are the result of a + comprehensive analysis of the project's codebase to identify and confirm the actual licenses of the components + used, which may differ from the initially declared licenses. While declared licenses provide an upfront + indication of the licensing intentions, concluded licenses offer a more thorough understanding of the actual + licensing within a project, facilitating proper compliance and risk management. Observed licenses are defined + in evidence.licenses. Observed licenses form the evidence necessary to substantiate a concluded license. + + Returns: + `LicenseAcknowledgement` or `None` + """ + return self._acknowledgement + + @acknowledgement.setter + def acknowledgement(self, acknowledgement: Optional[LicenseAcknowledgement]) -> None: + self._acknowledgement = acknowledgement + + @property + @serializable.xml_array(serializable.XmlArraySerializationType.FLAT, child_name='details') + @serializable.xml_sequence(1) + def expression_details(self) -> 'SortedSet[ExpressionDetails]': + """ + Details for parts of the expression. + + Returns: + `Iterable[ExpressionDetails]` if set else `None` + """ + return self._expression_details + + @expression_details.setter + def expression_details(self, expression_details: Iterable[ExpressionDetails]) -> None: + self._expression_details = SortedSet(expression_details) + + # @property + # ... + # @serializable.xml_array(serializable.XmlArraySerializationType.FLAT, child_name='licensing') + # @serializable.xml_sequence(2) + # def licensing(self) -> ...: + # ... # TODO + # + + @property + @serializable.xml_array(serializable.XmlArraySerializationType.NESTED, 'property') + @serializable.xml_sequence(3) + def properties(self) -> 'SortedSet[Property]': + """ + Provides the ability to document properties in a key/value store. This provides flexibility to include data not + officially supported in the standard without having to use additional namespaces or create extensions. + + Property names of interest to the general public are encouraged to be registered in the CycloneDX Property + Taxonomy - https://github.com/CycloneDX/cyclonedx-property-taxonomy. Formal registration is OPTIONAL. + + Return: + Set of `Property` + """ + return self._properties + + @properties.setter + def properties(self, properties: Iterable[Property]) -> None: + self._properties = SortedSet(properties) + + def __comparable_tuple(self) -> _ComparableTuple: + return _ComparableTuple(( + self._acknowledgement, + self._expression, + self._bom_ref.value, + _ComparableTuple(self.expression_details), + _ComparableTuple(self.properties), + )) + + def __hash__(self) -> int: + return hash(self.__comparable_tuple()) + + def __eq__(self, other: object) -> bool: + if isinstance(other, LicenseExpressionDetailed): + return self.__comparable_tuple() == other.__comparable_tuple() + return False + + def __lt__(self, other: Any) -> bool: + if isinstance(other, LicenseExpressionDetailed): + return self.__comparable_tuple() < other.__comparable_tuple() + if isinstance(other, LicenseExpression): + return True # self before any LicenseExpression + if isinstance(other, DisjunctiveLicense): + return False # self before any LicenseExpression + return NotImplemented + + def __repr__(self) -> str: + return f'' + + +License = Union[LicenseExpression, LicenseExpressionDetailed, DisjunctiveLicense] """TypeAlias for a union of supported license models. - :class:`LicenseExpression` +- :class:`LicenseExpressionDetailed` - :class:`DisjunctiveLicense` """ @@ -415,12 +691,27 @@ class LicenseRepository(SortedSet): class _LicenseRepositorySerializationHelper(serializable.helpers.BaseHelper): """ THIS CLASS IS NON-PUBLIC API """ + @staticmethod + def __supports_expression_details(view: Any) -> bool: + try: + return view is not None and view().schema_version_enum >= SchemaVersion.V1_7 + except Exception: # pragma: no cover + return False + @classmethod def json_normalize(cls, o: LicenseRepository, *, view: Optional[type[serializable.ViewType]], **__: Any) -> Any: if len(o) == 0: return None + + expression_detailed = next((li for li in o if isinstance(li, LicenseExpressionDetailed)), None) + if expression_detailed: + if cls.__supports_expression_details(view): + return [json_loads(expression_detailed.as_json(view_=view))] # type:ignore[attr-defined] + else: + warn('LicenseExpressionDetailed is not supported in schema versions before 1.7; skipping serialization') + expression = next((li for li in o if isinstance(li, LicenseExpression)), None) if expression: # mixed license expression and license? this is an invalid constellation according to schema! @@ -444,6 +735,10 @@ def json_denormalize(cls, o: list[dict[str, Any]], if 'license' in li: repo.add(DisjunctiveLicense.from_json( # type:ignore[attr-defined] li['license'])) + elif 'expressionDetails' in li: + repo.add(LicenseExpressionDetailed.from_json( # type:ignore[attr-defined] + li + )) elif 'expression' in li: repo.add(LicenseExpression.from_json( # type:ignore[attr-defined] li @@ -461,6 +756,15 @@ def xml_normalize(cls, o: LicenseRepository, *, if len(o) == 0: return None elem = Element(element_name) + + expression_detailed = next((li for li in o if isinstance(li, LicenseExpressionDetailed)), None) + if expression_detailed: + if cls.__supports_expression_details(view): + elem.append(expression_detailed.as_xml( # type:ignore[attr-defined] + view_=view, as_string=False, element_name='expression-detailed', xmlns=xmlns)) + else: + warn('LicenseExpressionDetailed is not supported in schema versions before 1.7; skipping serialization') + expression = next((li for li in o if isinstance(li, LicenseExpression)), None) if expression: # mixed license expression and license? this is an invalid constellation according to schema! @@ -487,6 +791,9 @@ def xml_denormalize(cls, o: Element, if tag == 'license': repo.add(DisjunctiveLicense.from_xml( # type:ignore[attr-defined] li, default_ns)) + elif tag == 'expression-detailed': + repo.add(LicenseExpressionDetailed.from_xml( # type:ignore[attr-defined] + li, default_ns)) elif tag == 'expression': repo.add(LicenseExpression.from_xml( # type:ignore[attr-defined] li, default_ns)) diff --git a/tests/_data/models.py b/tests/_data/models.py index 43d62570..98c0d3fd 100644 --- a/tests/_data/models.py +++ b/tests/_data/models.py @@ -97,7 +97,8 @@ ImpactAnalysisState, ) from cyclonedx.model.issue import IssueClassification, IssueType, IssueTypeSource -from cyclonedx.model.license import DisjunctiveLicense, License, LicenseAcknowledgement, LicenseExpression +from cyclonedx.model.license import DisjunctiveLicense, ExpressionDetails, License, LicenseAcknowledgement, \ + LicenseExpression, LicenseExpressionDetailed from cyclonedx.model.lifecycle import LifecyclePhase, NamedLifecycle, PredefinedLifecycle from cyclonedx.model.release_note import ReleaseNotes from cyclonedx.model.service import Service @@ -1061,6 +1062,15 @@ def get_vulnerability_source_owasp() -> VulnerabilitySource: def get_bom_with_licenses() -> Bom: + expression_details = [ + ExpressionDetails(license_identifier="GPL-3.0-or-later", + url=XsUri('https://www.apache.org/licenses/LICENSE-2.0.txt'), + text=AttachedText(content="specific GPL-3.0-or-later license text")), + ExpressionDetails(license_identifier="GPL-2.0", + bom_ref="some-bomref-1234", + text=AttachedText(content="specific GPL-2.0 license text")), + ] + return _make_bom( metadata=BomMetaData( licenses=[DisjunctiveLicense(id='CC-BY-1.0')], @@ -1082,6 +1092,11 @@ def get_bom_with_licenses() -> Bom: DisjunctiveLicense(name='some additional', text=AttachedText(content='this is additional license text')), ]), + Component(name='c-with-expression-details', type=ComponentType.LIBRARY, bom_ref='C4', + licenses=[LicenseExpressionDetailed(expression='GPL-3.0-or-later OR GPL-2.0', + expression_details=expression_details, + acknowledgement=LicenseAcknowledgement.DECLARED + )]), ], services=[ Service(name='s-with-expression', bom_ref='S1', diff --git a/tests/_data/snapshots/get_bom_with_licenses-1.0.xml.bin b/tests/_data/snapshots/get_bom_with_licenses-1.0.xml.bin index 1b308eaf..84da3adf 100644 --- a/tests/_data/snapshots/get_bom_with_licenses-1.0.xml.bin +++ b/tests/_data/snapshots/get_bom_with_licenses-1.0.xml.bin @@ -11,6 +11,11 @@ false + + c-with-expression-details + + false + c-with-name diff --git a/tests/_data/snapshots/get_bom_with_licenses-1.1.xml.bin b/tests/_data/snapshots/get_bom_with_licenses-1.1.xml.bin index e6f6adca..e50d6b66 100644 --- a/tests/_data/snapshots/get_bom_with_licenses-1.1.xml.bin +++ b/tests/_data/snapshots/get_bom_with_licenses-1.1.xml.bin @@ -18,6 +18,11 @@ Apache-2.0 OR MIT + + c-with-expression-details + + + c-with-name diff --git a/tests/_data/snapshots/get_bom_with_licenses-1.2.json.bin b/tests/_data/snapshots/get_bom_with_licenses-1.2.json.bin index c88a0812..8ba401b8 100644 --- a/tests/_data/snapshots/get_bom_with_licenses-1.2.json.bin +++ b/tests/_data/snapshots/get_bom_with_licenses-1.2.json.bin @@ -25,6 +25,13 @@ "type": "library", "version": "" }, + { + "bom-ref": "C4", + "licenses": [], + "name": "c-with-expression-details", + "type": "library", + "version": "" + }, { "bom-ref": "C3", "licenses": [ @@ -62,6 +69,9 @@ { "ref": "C3" }, + { + "ref": "C4" + }, { "ref": "S1" }, diff --git a/tests/_data/snapshots/get_bom_with_licenses-1.2.xml.bin b/tests/_data/snapshots/get_bom_with_licenses-1.2.xml.bin index 996e5716..7dc1f5ab 100644 --- a/tests/_data/snapshots/get_bom_with_licenses-1.2.xml.bin +++ b/tests/_data/snapshots/get_bom_with_licenses-1.2.xml.bin @@ -30,6 +30,11 @@ Apache-2.0 OR MIT + + c-with-expression-details + + + c-with-name @@ -79,6 +84,7 @@ + diff --git a/tests/_data/snapshots/get_bom_with_licenses-1.3.json.bin b/tests/_data/snapshots/get_bom_with_licenses-1.3.json.bin index a5407c58..551c37a7 100644 --- a/tests/_data/snapshots/get_bom_with_licenses-1.3.json.bin +++ b/tests/_data/snapshots/get_bom_with_licenses-1.3.json.bin @@ -25,6 +25,13 @@ "type": "library", "version": "" }, + { + "bom-ref": "C4", + "licenses": [], + "name": "c-with-expression-details", + "type": "library", + "version": "" + }, { "bom-ref": "C3", "licenses": [ @@ -62,6 +69,9 @@ { "ref": "C3" }, + { + "ref": "C4" + }, { "ref": "S1" }, diff --git a/tests/_data/snapshots/get_bom_with_licenses-1.3.xml.bin b/tests/_data/snapshots/get_bom_with_licenses-1.3.xml.bin index 1b53ee51..e00f8599 100644 --- a/tests/_data/snapshots/get_bom_with_licenses-1.3.xml.bin +++ b/tests/_data/snapshots/get_bom_with_licenses-1.3.xml.bin @@ -35,6 +35,11 @@ Apache-2.0 OR MIT + + c-with-expression-details + + + c-with-name @@ -84,6 +89,7 @@ + diff --git a/tests/_data/snapshots/get_bom_with_licenses-1.4.json.bin b/tests/_data/snapshots/get_bom_with_licenses-1.4.json.bin index a082d8a3..b38c2a8e 100644 --- a/tests/_data/snapshots/get_bom_with_licenses-1.4.json.bin +++ b/tests/_data/snapshots/get_bom_with_licenses-1.4.json.bin @@ -23,6 +23,12 @@ "name": "c-with-expression", "type": "library" }, + { + "bom-ref": "C4", + "licenses": [], + "name": "c-with-expression-details", + "type": "library" + }, { "bom-ref": "C3", "licenses": [ @@ -59,6 +65,9 @@ { "ref": "C3" }, + { + "ref": "C4" + }, { "ref": "S1" }, diff --git a/tests/_data/snapshots/get_bom_with_licenses-1.4.xml.bin b/tests/_data/snapshots/get_bom_with_licenses-1.4.xml.bin index 6d81479e..25b4df96 100644 --- a/tests/_data/snapshots/get_bom_with_licenses-1.4.xml.bin +++ b/tests/_data/snapshots/get_bom_with_licenses-1.4.xml.bin @@ -32,6 +32,10 @@ Apache-2.0 OR MIT + + c-with-expression-details + + c-with-name @@ -80,6 +84,7 @@ + diff --git a/tests/_data/snapshots/get_bom_with_licenses-1.5.json.bin b/tests/_data/snapshots/get_bom_with_licenses-1.5.json.bin index a8b28b10..411022bf 100644 --- a/tests/_data/snapshots/get_bom_with_licenses-1.5.json.bin +++ b/tests/_data/snapshots/get_bom_with_licenses-1.5.json.bin @@ -23,6 +23,12 @@ "name": "c-with-expression", "type": "library" }, + { + "bom-ref": "C4", + "licenses": [], + "name": "c-with-expression-details", + "type": "library" + }, { "bom-ref": "C3", "licenses": [ @@ -59,6 +65,9 @@ { "ref": "C3" }, + { + "ref": "C4" + }, { "ref": "S1" }, diff --git a/tests/_data/snapshots/get_bom_with_licenses-1.5.xml.bin b/tests/_data/snapshots/get_bom_with_licenses-1.5.xml.bin index fc2bedfd..edf7267b 100644 --- a/tests/_data/snapshots/get_bom_with_licenses-1.5.xml.bin +++ b/tests/_data/snapshots/get_bom_with_licenses-1.5.xml.bin @@ -32,6 +32,10 @@ Apache-2.0 OR MIT + + c-with-expression-details + + c-with-name @@ -80,6 +84,7 @@ + diff --git a/tests/_data/snapshots/get_bom_with_licenses-1.6.json.bin b/tests/_data/snapshots/get_bom_with_licenses-1.6.json.bin index 4e6ef33f..e683322c 100644 --- a/tests/_data/snapshots/get_bom_with_licenses-1.6.json.bin +++ b/tests/_data/snapshots/get_bom_with_licenses-1.6.json.bin @@ -25,6 +25,12 @@ "name": "c-with-expression", "type": "library" }, + { + "bom-ref": "C4", + "licenses": [], + "name": "c-with-expression-details", + "type": "library" + }, { "bom-ref": "C3", "licenses": [ @@ -61,6 +67,9 @@ { "ref": "C3" }, + { + "ref": "C4" + }, { "ref": "S1" }, diff --git a/tests/_data/snapshots/get_bom_with_licenses-1.6.xml.bin b/tests/_data/snapshots/get_bom_with_licenses-1.6.xml.bin index 49b31f46..1fc6bb2b 100644 --- a/tests/_data/snapshots/get_bom_with_licenses-1.6.xml.bin +++ b/tests/_data/snapshots/get_bom_with_licenses-1.6.xml.bin @@ -32,6 +32,10 @@ Apache-2.0 OR MIT + + c-with-expression-details + + c-with-name @@ -80,6 +84,7 @@ + diff --git a/tests/_data/snapshots/get_bom_with_licenses-1.7.json.bin b/tests/_data/snapshots/get_bom_with_licenses-1.7.json.bin index f095a469..00ed1b64 100644 --- a/tests/_data/snapshots/get_bom_with_licenses-1.7.json.bin +++ b/tests/_data/snapshots/get_bom_with_licenses-1.7.json.bin @@ -25,6 +25,35 @@ "name": "c-with-expression", "type": "library" }, + { + "bom-ref": "C4", + "licenses": [ + { + "acknowledgement": "declared", + "expression": "GPL-3.0-or-later OR GPL-2.0", + "expressionDetails": [ + { + "bom-ref": "some-bomref-1234", + "licenseIdentifier": "GPL-2.0", + "text": { + "content": "specific GPL-2.0 license text", + "contentType": "text/plain" + } + }, + { + "licenseIdentifier": "GPL-3.0-or-later", + "text": { + "content": "specific GPL-3.0-or-later license text", + "contentType": "text/plain" + }, + "url": "https://www.apache.org/licenses/LICENSE-2.0.txt" + } + ] + } + ], + "name": "c-with-expression-details", + "type": "library" + }, { "bom-ref": "C3", "licenses": [ @@ -61,6 +90,9 @@ { "ref": "C3" }, + { + "ref": "C4" + }, { "ref": "S1" }, diff --git a/tests/_data/snapshots/get_bom_with_licenses-1.7.xml.bin b/tests/_data/snapshots/get_bom_with_licenses-1.7.xml.bin index b9e91e6d..f261a00a 100644 --- a/tests/_data/snapshots/get_bom_with_licenses-1.7.xml.bin +++ b/tests/_data/snapshots/get_bom_with_licenses-1.7.xml.bin @@ -32,6 +32,20 @@ Apache-2.0 OR MIT + + c-with-expression-details + + +
+ specific GPL-2.0 license text +
+
+ specific GPL-3.0-or-later license text + https://www.apache.org/licenses/LICENSE-2.0.txt +
+
+
+
c-with-name @@ -80,6 +94,7 @@ + diff --git a/tests/test_model_license.py b/tests/test_model_license.py index 11443e48..65a51a08 100644 --- a/tests/test_model_license.py +++ b/tests/test_model_license.py @@ -21,8 +21,8 @@ from unittest.mock import MagicMock from cyclonedx.exception.model import MutuallyExclusivePropertiesException -from cyclonedx.model import AttachedText, XsUri -from cyclonedx.model.license import DisjunctiveLicense, LicenseExpression +from cyclonedx.model import AttachedText, XsUri, Property +from cyclonedx.model.license import DisjunctiveLicense, ExpressionDetails, LicenseExpression, LicenseExpressionDetailed from tests import reorder @@ -102,6 +102,49 @@ def test_equal(self) -> None: self.assertNotEqual(a, 'foo') +class TestModelLicenseExpressionDetailed(TestCase): + def test_create(self) -> None: + a = LicenseExpressionDetailed('foo') + self.assertEqual('foo', a.expression) + + details = [ + ExpressionDetails('qux'), + ExpressionDetails('baz') + ] + properties = [ + Property(name='prop1', value='val1'), + Property(name='prop2', value='val2'), + ] + b = LicenseExpressionDetailed('bar', expression_details=details, properties=properties) + self.assertListEqual(sorted(details), list(b.expression_details)) + self.assertIn(properties[0], b.properties) + self.assertIn(properties[1], b.properties) + + def test_update(self) -> None: + a = LicenseExpressionDetailed('foo') + self.assertEqual('foo', a.expression) + a.expression = 'bar' + self.assertEqual('bar', a.expression) + + details = [ + ExpressionDetails('qux'), + ExpressionDetails('baz') + ] + b = LicenseExpressionDetailed('bar', expression_details=[details[0]]) + b.expression_details.add(details[1]) + self.assertListEqual(sorted(details), list(b.expression_details)) + + def test_equal(self) -> None: + a = LicenseExpressionDetailed('foo') + b = LicenseExpressionDetailed('foo') + c = LicenseExpressionDetailed('bar') + d = LicenseExpressionDetailed('bar', expression_details=[ExpressionDetails('baz')]) + self.assertEqual(a, b) + self.assertNotEqual(a, c) + self.assertNotEqual(a, 'foo') + self.assertNotEqual(c, d) + + class TestModelLicense(TestCase): def test_sort_mixed(self) -> None: @@ -115,3 +158,25 @@ def test_sort_mixed(self) -> None: shuffle(licenses) sorted_licenses = sorted(licenses) self.assertListEqual(sorted_licenses, expected_licenses) + + +class TestModelExpressionDetails(TestCase): + def test_equal(self) -> None: + a = ExpressionDetails(license_identifier='MIT') + b = ExpressionDetails(license_identifier='MIT') + c = ExpressionDetails(license_identifier='MIT', text=AttachedText(content='some text')) + self.assertEqual(a, b) + self.assertNotEqual(a, c) + + def test_sort(self) -> None: + expected_order = [0, 3, 2, 1] + details = [ + ExpressionDetails(license_identifier='Apache-2.0'), + ExpressionDetails(license_identifier='MIT'), + ExpressionDetails(license_identifier='MIT'), + ExpressionDetails(license_identifier='GPL-3.0'), + ] + expected_details = reorder(details, expected_order) + shuffle(details) + sorted_details = sorted(details) + self.assertListEqual(sorted_details, expected_details) From 2fd2063f45ca636d6dbefd7c670a229284c22e96 Mon Sep 17 00:00:00 2001 From: Johannes Feichtner Date: Sun, 2 Nov 2025 21:39:47 +0100 Subject: [PATCH 2/5] use local import for SchemaVersion Signed-off-by: Johannes Feichtner --- cyclonedx/model/license.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cyclonedx/model/license.py b/cyclonedx/model/license.py index 1ec62422..064340d8 100644 --- a/cyclonedx/model/license.py +++ b/cyclonedx/model/license.py @@ -27,13 +27,13 @@ from xml.etree.ElementTree import Element # nosec B405 import py_serializable as serializable -from cyclonedx.schema import SchemaVersion from sortedcontainers import SortedSet from .._internal.bom_ref import bom_ref_from_str as _bom_ref_from_str from .._internal.compare import ComparableTuple as _ComparableTuple from ..exception.model import MutuallyExclusivePropertiesException from ..exception.serialization import CycloneDxDeserializationException +from ..schema import SchemaVersion from ..schema.schema import SchemaVersion1Dot5, SchemaVersion1Dot6, SchemaVersion1Dot7 from . import AttachedText, Property, XsUri from .bom_ref import BomRef From fdfea5a4c8c8489821fc36586ed29b42eecb1f75 Mon Sep 17 00:00:00 2001 From: Johannes Feichtner Date: Mon, 3 Nov 2025 22:30:04 +0100 Subject: [PATCH 3/5] fix linting errors Signed-off-by: Johannes Feichtner --- cyclonedx/model/license.py | 3 ++- tests/_data/models.py | 20 +++++++++++++------- tests/test_model_license.py | 2 +- 3 files changed, 16 insertions(+), 9 deletions(-) diff --git a/cyclonedx/model/license.py b/cyclonedx/model/license.py index 064340d8..2f4e1248 100644 --- a/cyclonedx/model/license.py +++ b/cyclonedx/model/license.py @@ -22,7 +22,8 @@ from enum import Enum from json import loads as json_loads -from typing import TYPE_CHECKING, Any, Iterable, Optional, Union +from typing import TYPE_CHECKING, Any, Optional, Union +from collections.abc import Iterable from warnings import warn from xml.etree.ElementTree import Element # nosec B405 diff --git a/tests/_data/models.py b/tests/_data/models.py index 98c0d3fd..beac302f 100644 --- a/tests/_data/models.py +++ b/tests/_data/models.py @@ -97,8 +97,14 @@ ImpactAnalysisState, ) from cyclonedx.model.issue import IssueClassification, IssueType, IssueTypeSource -from cyclonedx.model.license import DisjunctiveLicense, ExpressionDetails, License, LicenseAcknowledgement, \ - LicenseExpression, LicenseExpressionDetailed +from cyclonedx.model.license import ( + DisjunctiveLicense, + ExpressionDetails, + License, + LicenseAcknowledgement, + LicenseExpression, + LicenseExpressionDetailed, +) from cyclonedx.model.lifecycle import LifecyclePhase, NamedLifecycle, PredefinedLifecycle from cyclonedx.model.release_note import ReleaseNotes from cyclonedx.model.service import Service @@ -1063,12 +1069,12 @@ def get_vulnerability_source_owasp() -> VulnerabilitySource: def get_bom_with_licenses() -> Bom: expression_details = [ - ExpressionDetails(license_identifier="GPL-3.0-or-later", + ExpressionDetails(license_identifier='GPL-3.0-or-later', url=XsUri('https://www.apache.org/licenses/LICENSE-2.0.txt'), - text=AttachedText(content="specific GPL-3.0-or-later license text")), - ExpressionDetails(license_identifier="GPL-2.0", - bom_ref="some-bomref-1234", - text=AttachedText(content="specific GPL-2.0 license text")), + text=AttachedText(content='specific GPL-3.0-or-later license text')), + ExpressionDetails(license_identifier='GPL-2.0', + bom_ref='some-bomref-1234', + text=AttachedText(content='specific GPL-2.0 license text')), ] return _make_bom( diff --git a/tests/test_model_license.py b/tests/test_model_license.py index 65a51a08..a7b3906d 100644 --- a/tests/test_model_license.py +++ b/tests/test_model_license.py @@ -21,7 +21,7 @@ from unittest.mock import MagicMock from cyclonedx.exception.model import MutuallyExclusivePropertiesException -from cyclonedx.model import AttachedText, XsUri, Property +from cyclonedx.model import AttachedText, Property, XsUri from cyclonedx.model.license import DisjunctiveLicense, ExpressionDetails, LicenseExpression, LicenseExpressionDetailed from tests import reorder From 77fd7b7b69baba7e2c00d46b1db2b61714d9cdbd Mon Sep 17 00:00:00 2001 From: Johannes Feichtner <343448+Churro@users.noreply.github.com> Date: Wed, 5 Nov 2025 20:09:44 +0100 Subject: [PATCH 4/5] Update cyclonedx/model/license.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Signed-off-by: Johannes Feichtner <343448+Churro@users.noreply.github.com> --- cyclonedx/model/license.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cyclonedx/model/license.py b/cyclonedx/model/license.py index 2f4e1248..283c293d 100644 --- a/cyclonedx/model/license.py +++ b/cyclonedx/model/license.py @@ -641,7 +641,7 @@ def __lt__(self, other: Any) -> bool: if isinstance(other, LicenseExpression): return True # self before any LicenseExpression if isinstance(other, DisjunctiveLicense): - return False # self before any LicenseExpression + return False # self after any DisjunctiveLicense return NotImplemented def __repr__(self) -> str: From ebe9f2e846d0ec1ff5ac38ec1c2f87841a098103 Mon Sep 17 00:00:00 2001 From: Johannes Feichtner Date: Wed, 5 Nov 2025 21:20:28 +0100 Subject: [PATCH 5/5] add transpiling for CDX < 1.7 Signed-off-by: Johannes Feichtner --- cyclonedx/model/license.py | 41 ++++++++++++++++--- .../get_bom_with_licenses-1.1.xml.bin | 4 +- .../get_bom_with_licenses-1.2.json.bin | 6 ++- .../get_bom_with_licenses-1.2.xml.bin | 4 +- .../get_bom_with_licenses-1.3.json.bin | 6 ++- .../get_bom_with_licenses-1.3.xml.bin | 4 +- .../get_bom_with_licenses-1.4.json.bin | 6 ++- .../get_bom_with_licenses-1.4.xml.bin | 4 +- .../get_bom_with_licenses-1.5.json.bin | 6 ++- .../get_bom_with_licenses-1.5.xml.bin | 4 +- .../get_bom_with_licenses-1.6.json.bin | 7 +++- .../get_bom_with_licenses-1.6.xml.bin | 4 +- 12 files changed, 79 insertions(+), 17 deletions(-) diff --git a/cyclonedx/model/license.py b/cyclonedx/model/license.py index 283c293d..503a74bc 100644 --- a/cyclonedx/model/license.py +++ b/cyclonedx/model/license.py @@ -20,10 +20,10 @@ License related things """ +from collections.abc import Iterable from enum import Enum from json import loads as json_loads from typing import TYPE_CHECKING, Any, Optional, Union -from collections.abc import Iterable from warnings import warn from xml.etree.ElementTree import Element # nosec B405 @@ -519,6 +519,9 @@ def __init__( self.properties = properties or [] @property + @serializable.view(SchemaVersion1Dot5) + @serializable.view(SchemaVersion1Dot6) + @serializable.view(SchemaVersion1Dot7) @serializable.type_mapping(BomRef) @serializable.xml_attribute() @serializable.xml_name('bom-ref') @@ -551,6 +554,8 @@ def expression(self, expression: str) -> None: self._expression = expression @property + @serializable.view(SchemaVersion1Dot6) + @serializable.view(SchemaVersion1Dot7) @serializable.xml_attribute() def acknowledgement(self) -> Optional[LicenseAcknowledgement]: """ @@ -575,6 +580,7 @@ def acknowledgement(self, acknowledgement: Optional[LicenseAcknowledgement]) -> self._acknowledgement = acknowledgement @property + @serializable.view(SchemaVersion1Dot7) @serializable.xml_array(serializable.XmlArraySerializationType.FLAT, child_name='details') @serializable.xml_sequence(1) def expression_details(self) -> 'SortedSet[ExpressionDetails]': @@ -591,6 +597,7 @@ def expression_details(self, expression_details: Iterable[ExpressionDetails]) -> self._expression_details = SortedSet(expression_details) # @property + # @serializable.view(SchemaVersion1Dot7) # ... # @serializable.xml_array(serializable.XmlArraySerializationType.FLAT, child_name='licensing') # @serializable.xml_sequence(2) @@ -599,6 +606,7 @@ def expression_details(self, expression_details: Iterable[ExpressionDetails]) -> # @property + @serializable.view(SchemaVersion1Dot7) @serializable.xml_array(serializable.XmlArraySerializationType.NESTED, 'property') @serializable.xml_sequence(3) def properties(self) -> 'SortedSet[Property]': @@ -699,6 +707,27 @@ def __supports_expression_details(view: Any) -> bool: except Exception: # pragma: no cover return False + @staticmethod + def __transpile_license_expression_details_xml( + expression_detailed: LicenseExpressionDetailed, + view: Optional[type[serializable.ViewType]], + xmlns: Optional[str] + ) -> Element: + normalized: Element = expression_detailed.as_xml( # type:ignore[attr-defined] + view_=view, as_string=False, element_name='expression', xmlns=xmlns) + + ns_prefix = f'{{{xmlns}}}' if xmlns else '' + details = normalized.findall(f'./{ns_prefix}details') + for details_elem in details: + normalized.remove(details_elem) + + expression_value = normalized.get(f'{ns_prefix}expression') + if expression_value: + normalized.text = expression_value + del normalized.attrib[f'{ns_prefix}expression'] + + return normalized + @classmethod def json_normalize(cls, o: LicenseRepository, *, view: Optional[type[serializable.ViewType]], @@ -708,10 +737,9 @@ def json_normalize(cls, o: LicenseRepository, *, expression_detailed = next((li for li in o if isinstance(li, LicenseExpressionDetailed)), None) if expression_detailed: - if cls.__supports_expression_details(view): - return [json_loads(expression_detailed.as_json(view_=view))] # type:ignore[attr-defined] - else: - warn('LicenseExpressionDetailed is not supported in schema versions before 1.7; skipping serialization') + if not cls.__supports_expression_details(view): + warn('LicenseExpressionDetailed is not supported in schema versions < 1.7; ignoring expressionDetails') + return [json_loads(expression_detailed.as_json(view_=view))] # type:ignore[attr-defined] expression = next((li for li in o if isinstance(li, LicenseExpression)), None) if expression: @@ -764,7 +792,8 @@ def xml_normalize(cls, o: LicenseRepository, *, elem.append(expression_detailed.as_xml( # type:ignore[attr-defined] view_=view, as_string=False, element_name='expression-detailed', xmlns=xmlns)) else: - warn('LicenseExpressionDetailed is not supported in schema versions before 1.7; skipping serialization') + warn('LicenseExpressionDetailed is not supported in schema versions < 1.7; ignoring details') + elem.append(cls.__transpile_license_expression_details_xml(expression_detailed, view, xmlns)) expression = next((li for li in o if isinstance(li, LicenseExpression)), None) if expression: diff --git a/tests/_data/snapshots/get_bom_with_licenses-1.1.xml.bin b/tests/_data/snapshots/get_bom_with_licenses-1.1.xml.bin index e50d6b66..e4e8b370 100644 --- a/tests/_data/snapshots/get_bom_with_licenses-1.1.xml.bin +++ b/tests/_data/snapshots/get_bom_with_licenses-1.1.xml.bin @@ -21,7 +21,9 @@ c-with-expression-details - + + GPL-3.0-or-later OR GPL-2.0 + c-with-name diff --git a/tests/_data/snapshots/get_bom_with_licenses-1.2.json.bin b/tests/_data/snapshots/get_bom_with_licenses-1.2.json.bin index 8ba401b8..788e91c4 100644 --- a/tests/_data/snapshots/get_bom_with_licenses-1.2.json.bin +++ b/tests/_data/snapshots/get_bom_with_licenses-1.2.json.bin @@ -27,7 +27,11 @@ }, { "bom-ref": "C4", - "licenses": [], + "licenses": [ + { + "expression": "GPL-3.0-or-later OR GPL-2.0" + } + ], "name": "c-with-expression-details", "type": "library", "version": "" diff --git a/tests/_data/snapshots/get_bom_with_licenses-1.2.xml.bin b/tests/_data/snapshots/get_bom_with_licenses-1.2.xml.bin index 7dc1f5ab..a7fa713f 100644 --- a/tests/_data/snapshots/get_bom_with_licenses-1.2.xml.bin +++ b/tests/_data/snapshots/get_bom_with_licenses-1.2.xml.bin @@ -33,7 +33,9 @@ c-with-expression-details - + + GPL-3.0-or-later OR GPL-2.0 + c-with-name diff --git a/tests/_data/snapshots/get_bom_with_licenses-1.3.json.bin b/tests/_data/snapshots/get_bom_with_licenses-1.3.json.bin index 551c37a7..0b6d0394 100644 --- a/tests/_data/snapshots/get_bom_with_licenses-1.3.json.bin +++ b/tests/_data/snapshots/get_bom_with_licenses-1.3.json.bin @@ -27,7 +27,11 @@ }, { "bom-ref": "C4", - "licenses": [], + "licenses": [ + { + "expression": "GPL-3.0-or-later OR GPL-2.0" + } + ], "name": "c-with-expression-details", "type": "library", "version": "" diff --git a/tests/_data/snapshots/get_bom_with_licenses-1.3.xml.bin b/tests/_data/snapshots/get_bom_with_licenses-1.3.xml.bin index e00f8599..d473430f 100644 --- a/tests/_data/snapshots/get_bom_with_licenses-1.3.xml.bin +++ b/tests/_data/snapshots/get_bom_with_licenses-1.3.xml.bin @@ -38,7 +38,9 @@ c-with-expression-details - + + GPL-3.0-or-later OR GPL-2.0 + c-with-name diff --git a/tests/_data/snapshots/get_bom_with_licenses-1.4.json.bin b/tests/_data/snapshots/get_bom_with_licenses-1.4.json.bin index b38c2a8e..480d6f38 100644 --- a/tests/_data/snapshots/get_bom_with_licenses-1.4.json.bin +++ b/tests/_data/snapshots/get_bom_with_licenses-1.4.json.bin @@ -25,7 +25,11 @@ }, { "bom-ref": "C4", - "licenses": [], + "licenses": [ + { + "expression": "GPL-3.0-or-later OR GPL-2.0" + } + ], "name": "c-with-expression-details", "type": "library" }, diff --git a/tests/_data/snapshots/get_bom_with_licenses-1.4.xml.bin b/tests/_data/snapshots/get_bom_with_licenses-1.4.xml.bin index 25b4df96..9a86c99f 100644 --- a/tests/_data/snapshots/get_bom_with_licenses-1.4.xml.bin +++ b/tests/_data/snapshots/get_bom_with_licenses-1.4.xml.bin @@ -34,7 +34,9 @@ c-with-expression-details - + + GPL-3.0-or-later OR GPL-2.0 + c-with-name diff --git a/tests/_data/snapshots/get_bom_with_licenses-1.5.json.bin b/tests/_data/snapshots/get_bom_with_licenses-1.5.json.bin index 411022bf..a93db3a6 100644 --- a/tests/_data/snapshots/get_bom_with_licenses-1.5.json.bin +++ b/tests/_data/snapshots/get_bom_with_licenses-1.5.json.bin @@ -25,7 +25,11 @@ }, { "bom-ref": "C4", - "licenses": [], + "licenses": [ + { + "expression": "GPL-3.0-or-later OR GPL-2.0" + } + ], "name": "c-with-expression-details", "type": "library" }, diff --git a/tests/_data/snapshots/get_bom_with_licenses-1.5.xml.bin b/tests/_data/snapshots/get_bom_with_licenses-1.5.xml.bin index edf7267b..054ff7fc 100644 --- a/tests/_data/snapshots/get_bom_with_licenses-1.5.xml.bin +++ b/tests/_data/snapshots/get_bom_with_licenses-1.5.xml.bin @@ -34,7 +34,9 @@ c-with-expression-details - + + GPL-3.0-or-later OR GPL-2.0 + c-with-name diff --git a/tests/_data/snapshots/get_bom_with_licenses-1.6.json.bin b/tests/_data/snapshots/get_bom_with_licenses-1.6.json.bin index e683322c..eb5bc194 100644 --- a/tests/_data/snapshots/get_bom_with_licenses-1.6.json.bin +++ b/tests/_data/snapshots/get_bom_with_licenses-1.6.json.bin @@ -27,7 +27,12 @@ }, { "bom-ref": "C4", - "licenses": [], + "licenses": [ + { + "acknowledgement": "declared", + "expression": "GPL-3.0-or-later OR GPL-2.0" + } + ], "name": "c-with-expression-details", "type": "library" }, diff --git a/tests/_data/snapshots/get_bom_with_licenses-1.6.xml.bin b/tests/_data/snapshots/get_bom_with_licenses-1.6.xml.bin index 1fc6bb2b..9588f7cd 100644 --- a/tests/_data/snapshots/get_bom_with_licenses-1.6.xml.bin +++ b/tests/_data/snapshots/get_bom_with_licenses-1.6.xml.bin @@ -34,7 +34,9 @@ c-with-expression-details - + + GPL-3.0-or-later OR GPL-2.0 + c-with-name