2222
2323from enum import Enum
2424from 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
2626from warnings import warn
2727from xml .etree .ElementTree import Element # nosec B405
2828
2929import py_serializable as serializable
30+ from cyclonedx .schema import SchemaVersion
3031from sortedcontainers import SortedSet
3132
3233from .._internal .bom_ref import bom_ref_from_str as _bom_ref_from_str
3334from .._internal .compare import ComparableTuple as _ComparableTuple
3435from ..exception .model import MutuallyExclusivePropertiesException
3536from ..exception .serialization import CycloneDxDeserializationException
3637from ..schema .schema import SchemaVersion1Dot5 , SchemaVersion1Dot6 , SchemaVersion1Dot7
37- from . import AttachedText , XsUri
38+ from . import AttachedText , Property , XsUri
3839from .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):
415691class _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