Skip to content

Commit f06071d

Browse files
committed
feat: add support for license expression details
Signed-off-by: Johannes Feichtner <johannes@web-wack.at>
1 parent aff9636 commit f06071d

17 files changed

Lines changed: 524 additions & 6 deletions

cyclonedx/model/license.py

Lines changed: 310 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,19 +22,20 @@
2222

2323
from enum import Enum
2424
from json import loads as json_loads
25-
from typing import TYPE_CHECKING, Any, Optional, Union
25+
from typing import TYPE_CHECKING, Any, Iterable, Optional, Union
2626
from warnings import warn
2727
from xml.etree.ElementTree import Element # nosec B405
2828

2929
import py_serializable as serializable
30+
from cyclonedx.schema import SchemaVersion
3031
from sortedcontainers import SortedSet
3132

3233
from .._internal.bom_ref import bom_ref_from_str as _bom_ref_from_str
3334
from .._internal.compare import ComparableTuple as _ComparableTuple
3435
from ..exception.model import MutuallyExclusivePropertiesException
3536
from ..exception.serialization import CycloneDxDeserializationException
3637
from ..schema.schema import SchemaVersion1Dot5, SchemaVersion1Dot6, SchemaVersion1Dot7
37-
from . import AttachedText, XsUri
38+
from . import AttachedText, Property, XsUri
3839
from .bom_ref import BomRef
3940

4041

@@ -251,6 +252,8 @@ def __eq__(self, other: object) -> bool:
251252
def __lt__(self, other: Any) -> bool:
252253
if isinstance(other, DisjunctiveLicense):
253254
return self.__comparable_tuple() < other.__comparable_tuple()
255+
if isinstance(other, LicenseExpressionDetailed):
256+
return False # self after any LicenseExpressionDetailed
254257
if isinstance(other, LicenseExpression):
255258
return False # self after any LicenseExpression
256259
return NotImplemented
@@ -364,6 +367,8 @@ def __eq__(self, other: object) -> bool:
364367
def __lt__(self, other: Any) -> bool:
365368
if isinstance(other, LicenseExpression):
366369
return self.__comparable_tuple() < other.__comparable_tuple()
370+
if isinstance(other, LicenseExpressionDetailed):
371+
return False # self after any LicenseExpressionDetailed
367372
if isinstance(other, DisjunctiveLicense):
368373
return True # self before any DisjunctiveLicense
369374
return NotImplemented
@@ -372,10 +377,281 @@ def __repr__(self) -> str:
372377
return f'<LicenseExpression value={self._value!r}>'
373378

374379

375-
License = Union[LicenseExpression, DisjunctiveLicense]
380+
@serializable.serializable_class(ignore_unknown_during_deserialization=True)
381+
class ExpressionDetails:
382+
"""
383+
This is our internal representation of the `licenseExpressionDetailedType` complex type that specifies the details
384+
and attributes related to a software license identifier within a CycloneDX BOM document.
385+
386+
.. note::
387+
Introduced in CycloneDX v1.7
388+
389+
390+
.. note::
391+
See the CycloneDX Schema definition: https://cyclonedx.org/docs/1.7/xml/#type_licenseExpressionDetailedType
392+
"""
393+
394+
def __init__(
395+
self, license_identifier: str, *,
396+
bom_ref: Optional[Union[str, BomRef]] = None,
397+
text: Optional[AttachedText] = None,
398+
url: Optional[XsUri] = None,
399+
) -> None:
400+
self._bom_ref = _bom_ref_from_str(bom_ref)
401+
self.license_identifier = license_identifier
402+
self.text = text
403+
self.url = url
404+
405+
@property
406+
@serializable.xml_name('license-identifier')
407+
@serializable.xml_string(serializable.XmlStringSerializationType.NORMALIZED_STRING)
408+
@serializable.xml_attribute()
409+
def license_identifier(self) -> str:
410+
"""
411+
A valid SPDX license identifier. Refer to https://spdx.org/specifications for syntax requirements.
412+
This field serves as the primary key, which uniquely identifies each record.
413+
414+
Example values:
415+
- "Apache-2.0",
416+
- "GPL-3.0-only WITH Classpath-exception-2.0"
417+
- "LicenseRef-my-custom-license"
418+
419+
Returns:
420+
`str`
421+
"""
422+
return self._license_identifier
423+
424+
@license_identifier.setter
425+
def license_identifier(self, license_identifier: str) -> None:
426+
self._license_identifier = license_identifier
427+
428+
@property
429+
@serializable.json_name('bom-ref')
430+
@serializable.type_mapping(BomRef)
431+
@serializable.xml_attribute()
432+
@serializable.xml_name('bom-ref')
433+
def bom_ref(self) -> BomRef:
434+
"""
435+
An optional identifier which can be used to reference the component elsewhere in the BOM. Every bom-ref MUST be
436+
unique within the BOM.
437+
438+
Returns:
439+
`BomRef`
440+
"""
441+
return self._bom_ref
442+
443+
@property
444+
@serializable.xml_sequence(1)
445+
def text(self) -> Optional[AttachedText]:
446+
"""
447+
Specifies the optional full text of the attachment
448+
449+
Returns:
450+
`AttachedText` else `None`
451+
"""
452+
return self._text
453+
454+
@text.setter
455+
def text(self, text: Optional[AttachedText]) -> None:
456+
self._text = text
457+
458+
@property
459+
@serializable.xml_sequence(2)
460+
def url(self) -> Optional[XsUri]:
461+
"""
462+
The URL to the attachment file. If the attachment is a license or BOM, an externalReference should also be
463+
specified for completeness.
464+
465+
Returns:
466+
`XsUri` or `None`
467+
"""
468+
return self._url
469+
470+
@url.setter
471+
def url(self, url: Optional[XsUri]) -> None:
472+
self._url = url
473+
474+
def __comparable_tuple(self) -> _ComparableTuple:
475+
return _ComparableTuple((
476+
self.bom_ref.value, self.license_identifier, self.url, self.text,
477+
))
478+
479+
def __eq__(self, other: object) -> bool:
480+
if isinstance(other, ExpressionDetails):
481+
return self.__comparable_tuple() == other.__comparable_tuple()
482+
return False
483+
484+
def __lt__(self, other: object) -> bool:
485+
if isinstance(other, ExpressionDetails):
486+
return self.__comparable_tuple() < other.__comparable_tuple()
487+
return NotImplemented
488+
489+
def __hash__(self) -> int:
490+
return hash(self.__comparable_tuple())
491+
492+
def __repr__(self) -> str:
493+
return f'<ExpressionDetails bom-ref={self.bom_ref!r}, license_identifier={self.license_identifier}>'
494+
495+
496+
@serializable.serializable_class(ignore_unknown_during_deserialization=True)
497+
class LicenseExpressionDetailed:
498+
"""
499+
Specifies the details and attributes related to a software license.
500+
It must be a valid SPDX license expression, along with additional properties such as license acknowledgment.
501+
502+
.. note::
503+
See the CycloneDX Schema definition:
504+
https://cyclonedx.org/docs/1.7/json/#components_items_licenses_items_oneOf_i1_expressionDetails
505+
"""
506+
507+
def __init__(
508+
self, expression: str, *,
509+
expression_details: Optional[Iterable[ExpressionDetails]] = None,
510+
bom_ref: Optional[Union[str, BomRef]] = None,
511+
acknowledgement: Optional[LicenseAcknowledgement] = None,
512+
properties: Optional[Iterable[Property]] = None,
513+
) -> None:
514+
self._bom_ref = _bom_ref_from_str(bom_ref)
515+
self.expression = expression
516+
self.acknowledgement = acknowledgement
517+
self.expression_details = expression_details or []
518+
self.properties = properties or []
519+
520+
@property
521+
@serializable.type_mapping(BomRef)
522+
@serializable.xml_attribute()
523+
@serializable.xml_name('bom-ref')
524+
@serializable.json_name('bom-ref')
525+
def bom_ref(self) -> BomRef:
526+
"""
527+
An optional identifier which can be used to reference the component elsewhere in the BOM. Every bom-ref MUST be
528+
unique within the BOM.
529+
530+
Returns:
531+
`BomRef`
532+
"""
533+
return self._bom_ref
534+
535+
@property
536+
@serializable.xml_string(serializable.XmlStringSerializationType.NORMALIZED_STRING)
537+
@serializable.xml_attribute()
538+
def expression(self) -> str:
539+
"""
540+
A valid SPDX license expression.
541+
Refer to https://spdx.org/specifications for syntax requirements.
542+
543+
Returns:
544+
`str`
545+
"""
546+
return self._expression
547+
548+
@expression.setter
549+
def expression(self, expression: str) -> None:
550+
self._expression = expression
551+
552+
@property
553+
@serializable.xml_attribute()
554+
def acknowledgement(self) -> Optional[LicenseAcknowledgement]:
555+
"""
556+
Declared licenses and concluded licenses represent two different stages in the licensing process within
557+
software development.
558+
559+
Declared licenses refer to the initial intention of the software authors regarding the
560+
licensing terms under which their code is released. On the other hand, concluded licenses are the result of a
561+
comprehensive analysis of the project's codebase to identify and confirm the actual licenses of the components
562+
used, which may differ from the initially declared licenses. While declared licenses provide an upfront
563+
indication of the licensing intentions, concluded licenses offer a more thorough understanding of the actual
564+
licensing within a project, facilitating proper compliance and risk management. Observed licenses are defined
565+
in evidence.licenses. Observed licenses form the evidence necessary to substantiate a concluded license.
566+
567+
Returns:
568+
`LicenseAcknowledgement` or `None`
569+
"""
570+
return self._acknowledgement
571+
572+
@acknowledgement.setter
573+
def acknowledgement(self, acknowledgement: Optional[LicenseAcknowledgement]) -> None:
574+
self._acknowledgement = acknowledgement
575+
576+
@property
577+
@serializable.xml_array(serializable.XmlArraySerializationType.FLAT, child_name='details')
578+
@serializable.xml_sequence(1)
579+
def expression_details(self) -> 'SortedSet[ExpressionDetails]':
580+
"""
581+
Details for parts of the expression.
582+
583+
Returns:
584+
`Iterable[ExpressionDetails]` if set else `None`
585+
"""
586+
return self._expression_details
587+
588+
@expression_details.setter
589+
def expression_details(self, expression_details: Iterable[ExpressionDetails]) -> None:
590+
self._expression_details = SortedSet(expression_details)
591+
592+
# @property
593+
# ...
594+
# @serializable.xml_array(serializable.XmlArraySerializationType.FLAT, child_name='licensing')
595+
# @serializable.xml_sequence(2)
596+
# def licensing(self) -> ...:
597+
# ... # TODO
598+
#
599+
600+
@property
601+
@serializable.xml_array(serializable.XmlArraySerializationType.NESTED, 'property')
602+
@serializable.xml_sequence(3)
603+
def properties(self) -> 'SortedSet[Property]':
604+
"""
605+
Provides the ability to document properties in a key/value store. This provides flexibility to include data not
606+
officially supported in the standard without having to use additional namespaces or create extensions.
607+
608+
Property names of interest to the general public are encouraged to be registered in the CycloneDX Property
609+
Taxonomy - https://github.com/CycloneDX/cyclonedx-property-taxonomy. Formal registration is OPTIONAL.
610+
611+
Return:
612+
Set of `Property`
613+
"""
614+
return self._properties
615+
616+
@properties.setter
617+
def properties(self, properties: Iterable[Property]) -> None:
618+
self._properties = SortedSet(properties)
619+
620+
def __comparable_tuple(self) -> _ComparableTuple:
621+
return _ComparableTuple((
622+
self._acknowledgement,
623+
self._expression,
624+
self._bom_ref.value,
625+
_ComparableTuple(self.expression_details),
626+
_ComparableTuple(self.properties),
627+
))
628+
629+
def __hash__(self) -> int:
630+
return hash(self.__comparable_tuple())
631+
632+
def __eq__(self, other: object) -> bool:
633+
if isinstance(other, LicenseExpressionDetailed):
634+
return self.__comparable_tuple() == other.__comparable_tuple()
635+
return False
636+
637+
def __lt__(self, other: Any) -> bool:
638+
if isinstance(other, LicenseExpressionDetailed):
639+
return self.__comparable_tuple() < other.__comparable_tuple()
640+
if isinstance(other, LicenseExpression):
641+
return True # self before any LicenseExpression
642+
if isinstance(other, DisjunctiveLicense):
643+
return False # self before any LicenseExpression
644+
return NotImplemented
645+
646+
def __repr__(self) -> str:
647+
return f'<LicenseExpressionDetailed expression={self._expression!r}>'
648+
649+
650+
License = Union[LicenseExpression, LicenseExpressionDetailed, DisjunctiveLicense]
376651
"""TypeAlias for a union of supported license models.
377652
378653
- :class:`LicenseExpression`
654+
- :class:`LicenseExpressionDetailed`
379655
- :class:`DisjunctiveLicense`
380656
"""
381657

@@ -415,12 +691,27 @@ class LicenseRepository(SortedSet):
415691
class _LicenseRepositorySerializationHelper(serializable.helpers.BaseHelper):
416692
""" THIS CLASS IS NON-PUBLIC API """
417693

694+
@staticmethod
695+
def __supports_expression_details(view: Any) -> bool:
696+
try:
697+
return view is not None and view().schema_version_enum >= SchemaVersion.V1_7
698+
except Exception: # pragma: no cover
699+
return False
700+
418701
@classmethod
419702
def json_normalize(cls, o: LicenseRepository, *,
420703
view: Optional[type[serializable.ViewType]],
421704
**__: Any) -> Any:
422705
if len(o) == 0:
423706
return None
707+
708+
expression_detailed = next((li for li in o if isinstance(li, LicenseExpressionDetailed)), None)
709+
if expression_detailed:
710+
if cls.__supports_expression_details(view):
711+
return [json_loads(expression_detailed.as_json(view_=view))] # type:ignore[attr-defined]
712+
else:
713+
warn('LicenseExpressionDetailed is not supported in schema versions before 1.7; skipping serialization')
714+
424715
expression = next((li for li in o if isinstance(li, LicenseExpression)), None)
425716
if expression:
426717
# 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]],
444735
if 'license' in li:
445736
repo.add(DisjunctiveLicense.from_json( # type:ignore[attr-defined]
446737
li['license']))
738+
elif 'expressionDetails' in li:
739+
repo.add(LicenseExpressionDetailed.from_json( # type:ignore[attr-defined]
740+
li
741+
))
447742
elif 'expression' in li:
448743
repo.add(LicenseExpression.from_json( # type:ignore[attr-defined]
449744
li
@@ -461,6 +756,15 @@ def xml_normalize(cls, o: LicenseRepository, *,
461756
if len(o) == 0:
462757
return None
463758
elem = Element(element_name)
759+
760+
expression_detailed = next((li for li in o if isinstance(li, LicenseExpressionDetailed)), None)
761+
if expression_detailed:
762+
if cls.__supports_expression_details(view):
763+
elem.append(expression_detailed.as_xml( # type:ignore[attr-defined]
764+
view_=view, as_string=False, element_name='expression-detailed', xmlns=xmlns))
765+
else:
766+
warn('LicenseExpressionDetailed is not supported in schema versions before 1.7; skipping serialization')
767+
464768
expression = next((li for li in o if isinstance(li, LicenseExpression)), None)
465769
if expression:
466770
# mixed license expression and license? this is an invalid constellation according to schema!
@@ -487,6 +791,9 @@ def xml_denormalize(cls, o: Element,
487791
if tag == 'license':
488792
repo.add(DisjunctiveLicense.from_xml( # type:ignore[attr-defined]
489793
li, default_ns))
794+
elif tag == 'expression-detailed':
795+
repo.add(LicenseExpressionDetailed.from_xml( # type:ignore[attr-defined]
796+
li, default_ns))
490797
elif tag == 'expression':
491798
repo.add(LicenseExpression.from_xml( # type:ignore[attr-defined]
492799
li, default_ns))

0 commit comments

Comments
 (0)