Skip to content

Commit 0993a4c

Browse files
authored
Merge branch 'main' into issue941-test
2 parents ca1946f + 150777e commit 0993a4c

23 files changed

Lines changed: 429 additions & 9 deletions

.github/workflows/release.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,7 @@ jobs:
140140
architecture: 'x64'
141141
- name: Install and configure Poetry
142142
# Seehttps://github.com/snok/install-poetry
143-
uses: snok/install-poetry@76e04a911780d5b312d89783f7b1cd627778900a # v1.4.1
143+
uses: snok/install-poetry@a783c322200f0519c7926aa6faa857c4e23e9263 # v1.4.2
144144
with:
145145
version: ${{ env.POETRY_VERSION }}
146146
virtualenvs-create: true

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,15 @@
22

33
<!-- version list -->
44

5+
## v11.9.0 (2026-06-08)
6+
7+
### Features
8+
9+
- Add support for license expression details
10+
([#908](https://github.com/CycloneDX/cyclonedx-python-lib/pull/908),
11+
[`b502381`](https://github.com/CycloneDX/cyclonedx-python-lib/commit/b50238102553dc215b08796ea914072294f69489))
12+
13+
514
## v11.8.0 (2026-06-04)
615

716
### Documentation

cyclonedx/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,4 +22,4 @@
2222

2323
# !! version is managed by semantic_release
2424
# do not use typing here, or else `semantic_release` might have issues finding the variable
25-
__version__ = "11.8.0" # noqa:Q000
25+
__version__ = "11.9.0" # noqa:Q000

cyclonedx/model/license.py

Lines changed: 180 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,123 @@ 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 LicenseExpressionDetails:
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 identifier which can be used to reference the license elsewhere in the BOM. Every bom-ref MUST be
338+
unique within the BOM.
339+
Value SHOULD not start with the BOM-Link intro 'urn:cdx:' to avoid conflicts with BOM-Links.
340+
341+
Returns:
342+
`BomRef`
343+
"""
344+
return self._bom_ref
345+
346+
@property
347+
@serializable.xml_sequence(1)
348+
def text(self) -> Optional[AttachedText]:
349+
"""
350+
A way to include the textual content of the license.
351+
352+
Returns:
353+
`AttachedText` else `None`
354+
"""
355+
return self._text
356+
357+
@text.setter
358+
def text(self, text: Optional[AttachedText]) -> None:
359+
self._text = text
360+
361+
@property
362+
@serializable.xml_sequence(2)
363+
def url(self) -> Optional[XsUri]:
364+
"""
365+
The URL to the license file. If specified, a 'license' externalReference should also be specified for
366+
completeness.
367+
368+
Returns:
369+
`XsUri` or `None`
370+
"""
371+
return self._url
372+
373+
@url.setter
374+
def url(self, url: Optional[XsUri]) -> None:
375+
self._url = url
376+
377+
def __comparable_tuple(self) -> _ComparableTuple:
378+
return _ComparableTuple((
379+
self.bom_ref.value, self.license_identifier, self.url, self.text,
380+
))
381+
382+
def __eq__(self, other: object) -> bool:
383+
if isinstance(other, LicenseExpressionDetails):
384+
return self.__comparable_tuple() == other.__comparable_tuple()
385+
return False
386+
387+
def __lt__(self, other: object) -> bool:
388+
if isinstance(other, LicenseExpressionDetails):
389+
return self.__comparable_tuple() < other.__comparable_tuple()
390+
return NotImplemented
391+
392+
def __hash__(self) -> int:
393+
return hash(self.__comparable_tuple())
394+
395+
def __repr__(self) -> str:
396+
return f'<LicenseExpressionDetails bom-ref={self.bom_ref!r}, license_identifier={self.license_identifier}>'
397+
398+
281399
@serializable.serializable_class(
282400
name='expression',
283401
ignore_unknown_during_deserialization=True
@@ -296,10 +414,12 @@ def __init__(
296414
self, value: str, *,
297415
bom_ref: Optional[Union[str, BomRef]] = None,
298416
acknowledgement: Optional[LicenseAcknowledgement] = None,
417+
details: Optional[Iterable[LicenseExpressionDetails]] = None,
299418
) -> None:
300419
self._bom_ref = _bom_ref_from_str(bom_ref)
301420
self._value = value
302421
self._acknowledgement = acknowledgement
422+
self.details = details or []
303423

304424
@property
305425
@serializable.view(SchemaVersion1Dot5)
@@ -362,11 +482,30 @@ def acknowledgement(self) -> Optional[LicenseAcknowledgement]:
362482
def acknowledgement(self, acknowledgement: Optional[LicenseAcknowledgement]) -> None:
363483
self._acknowledgement = acknowledgement
364484

485+
@property
486+
@serializable.json_name('expressionDetails')
487+
@serializable.view(SchemaVersion1Dot7)
488+
@serializable.xml_array(serializable.XmlArraySerializationType.FLAT, child_name='details')
489+
@serializable.xml_sequence(1)
490+
def details(self) -> 'SortedSet[LicenseExpressionDetails]':
491+
"""
492+
Details for parts of the expression.
493+
494+
Returns:
495+
Set of `LicenseExpressionDetails`
496+
"""
497+
return self._details
498+
499+
@details.setter
500+
def details(self, details: Iterable[LicenseExpressionDetails]) -> None:
501+
self._details = SortedSet(details)
502+
365503
def __comparable_tuple(self) -> _ComparableTuple:
366504
return _ComparableTuple((
367505
self._acknowledgement,
368506
self._value,
369507
self._bom_ref.value,
508+
_ComparableTuple(self.details),
370509
))
371510

372511
def __hash__(self) -> int:
@@ -431,6 +570,38 @@ class LicenseRepository(SortedSet):
431570
class _LicenseRepositorySerializationHelper(serializable.helpers.BaseHelper):
432571
""" THIS CLASS IS NON-PUBLIC API """
433572

573+
@staticmethod
574+
def __supports_expression_details(view: Any) -> bool:
575+
try:
576+
return view is not None and view().schema_version_enum >= SchemaVersion.V1_7
577+
except Exception: # pragma: no cover
578+
return False
579+
580+
@staticmethod
581+
def __xml_normalize_license_expression_detailed(
582+
license_expression: LicenseExpression,
583+
view: Optional[type[serializable.ViewType]],
584+
xmlns: Optional[str]
585+
) -> Element:
586+
elem: Element = license_expression.as_xml( # type:ignore[attr-defined]
587+
view_=view, as_string=False, element_name='expression-detailed', xmlns=xmlns)
588+
elem.set(f'{{{xmlns}}}expression' if xmlns else 'expression', license_expression.value)
589+
elem.text = None
590+
return elem
591+
592+
@staticmethod
593+
def __xml_denormalize_license_expression_detailed(
594+
li: Element,
595+
default_ns: Optional[str]
596+
) -> LicenseExpression:
597+
expression_value = li.get('expression')
598+
if not expression_value:
599+
raise CycloneDxDeserializationException(f'unexpected content: {li!r}')
600+
license_expression: LicenseExpression = LicenseExpression.from_xml( # type:ignore[attr-defined]
601+
li, default_ns)
602+
license_expression.value = expression_value
603+
return license_expression
604+
434605
@classmethod
435606
def json_normalize(cls, o: LicenseRepository, *,
436607
view: Optional[type[serializable.ViewType]],
@@ -482,8 +653,13 @@ def xml_normalize(cls, o: LicenseRepository, *,
482653
# mixed license expression and license? this is an invalid constellation according to schema!
483654
# see https://github.com/CycloneDX/specification/pull/205
484655
# 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))
656+
if expression.details and cls.__supports_expression_details(view):
657+
elem.append(cls.__xml_normalize_license_expression_detailed(expression, view, xmlns))
658+
else:
659+
if expression.details:
660+
warn('LicenseExpression details are not supported in schema versions < 1.7; skipping serialization')
661+
elem.append(expression.as_xml( # type:ignore[attr-defined]
662+
view_=view, as_string=False, element_name='expression', xmlns=xmlns))
487663
else:
488664
elem.extend(
489665
li.as_xml( # type:ignore[attr-defined]
@@ -506,6 +682,8 @@ def xml_denormalize(cls, o: Element,
506682
elif tag == 'expression':
507683
repo.add(LicenseExpression.from_xml( # type:ignore[attr-defined]
508684
li, default_ns))
685+
elif tag == 'expression-detailed':
686+
repo.add(cls.__xml_denormalize_license_expression_detailed(li, default_ns))
509687
else:
510688
raise CycloneDxDeserializationException(f'unexpected: {li!r}')
511689
return repo

docs/conf.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323

2424
# The full version, including alpha/beta/rc tags
2525
# !! version is managed by semantic_release
26-
release = '11.8.0'
26+
release = '11.9.0'
2727

2828
# -- General configuration ---------------------------------------------------
2929

docs/requirements.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
m2r2>=0.3.2
1+
m2r2>=0.3.4
22
sphinx>=8,<9
33
sphinx-autoapi>=3,<4
44
sphinx-rtd-theme>=3,<4

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ build-backend = "poetry.core.masonry.api"
55
[tool.poetry]
66
name = "cyclonedx-python-lib"
77
# !! version is managed by semantic_release
8-
version = "11.8.0"
8+
version = "11.9.0"
99
description = "Python library for CycloneDX"
1010
authors = [
1111
"Paul Horton <phorton@sonatype.com>",

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+
License,
103+
LicenseAcknowledgement,
104+
LicenseExpression,
105+
LicenseExpressionDetails,
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+
LicenseExpressionDetails(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+
LicenseExpressionDetails(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/>

0 commit comments

Comments
 (0)