3434from .._internal .compare import ComparableTuple as _ComparableTuple
3535from ..exception .model import MutuallyExclusivePropertiesException
3636from ..exception .serialization import CycloneDxDeserializationException
37+ from ..schema import SchemaVersion
3738from ..schema .schema import SchemaVersion1Dot5 , SchemaVersion1Dot6 , SchemaVersion1Dot7
3839from . import AttachedText , Property , XsUri
3940from .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):
431569class _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
0 commit comments