diff --git a/cyclonedx/model/component.py b/cyclonedx/model/component.py index 6e26947e..ea398543 100644 --- a/cyclonedx/model/component.py +++ b/cyclonedx/model/component.py @@ -76,7 +76,8 @@ class Commit: """ def __init__( - self, *, + self, + *, uid: Optional[str] = None, url: Optional[XsUri] = None, author: Optional[IdentifiableAction] = None, @@ -168,11 +169,9 @@ def message(self, message: Optional[str]) -> None: self._message = message def __comparable_tuple(self) -> _ComparableTuple: - return _ComparableTuple(( - self.uid, self.url, - self.author, self.committer, - self.message - )) + return _ComparableTuple( + (self.uid, self.url, self.author, self.committer, self.message) + ) def __eq__(self, other: object) -> bool: if isinstance(other, Commit): @@ -188,7 +187,520 @@ def __hash__(self) -> int: return hash(self.__comparable_tuple()) def __repr__(self) -> str: - return f'' + return f"" + + +@serializable.serializable_enum +class IdentityFieldType(str, Enum): + """ + Enum object that defines the permissible field types for Identity. + + .. note:: + See the CycloneDX Schema definition: https://cyclonedx.org/docs/1.6/json/#components_items_evidence_identity + """ + + GROUP = 'group' + NAME = 'name' + VERSION = 'version' + PURL = 'purl' + CPE = 'cpe' + OMNIBOR_ID = 'omniborId' + SWHID = 'swhid' + SWID = 'swid' + HASH = 'hash' + + +@serializable.serializable_enum +class AnalysisTechnique(str, Enum): + """ + Enum object that defines the permissible analysis techniques. + """ + + SOURCE_CODE_ANALYSIS = 'source-code-analysis' + BINARY_ANALYSIS = 'binary-analysis' + MANIFEST_ANALYSIS = 'manifest-analysis' + AST_FINGERPRINT = 'ast-fingerprint' + HASH_COMPARISON = 'hash-comparison' + INSTRUMENTATION = 'instrumentation' + DYNAMIC_ANALYSIS = 'dynamic-analysis' + FILENAME = 'filename' + ATTESTATION = 'attestation' + OTHER = 'other' + + +@serializable.serializable_class +class Method: + """ + Represents a method used to extract and/or analyze evidence. + """ + + def __init__( + self, + *, + technique: Union[AnalysisTechnique, str], + confidence: float, + value: Optional[str] = None, + ) -> None: + if isinstance(technique, str): + try: + technique = AnalysisTechnique(technique) + except ValueError: + raise ValueError( + f"Technique must be one of: {', '.join(t.value for t in AnalysisTechnique)}" + ) + + if not 0 <= confidence <= 1: + raise ValueError("Confidence must be between 0 and 1") + + self.technique = technique + self.confidence = confidence + self.value = value + + def __comparable_tuple(self) -> _ComparableTuple: + return _ComparableTuple( + ( + # Use technique.value to compare by string value for consistent ordering + self.technique.value, + self.confidence, + self.value, + ) + ) + + def __eq__(self, other: object) -> bool: + if isinstance(other, Method): + return self.__comparable_tuple() == other.__comparable_tuple() + return False + + def __lt__(self, other: Any) -> bool: + if isinstance(other, Method): + 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 +class Identity: + """ + Our internal representation of the `identityType` complex type. + + .. note:: + See the CycloneDX Schema definition: hhttps://cyclonedx.org/docs/1.6/json/#components_items_evidence_identity + """ + + def __init__( + self, + *, + field: Union[IdentityFieldType, str], # Accept either enum or string + confidence: Optional[int] = None, + concludedValue: Optional[str] = None, + methods: Optional[Iterable[Method]] = None, # Updated type + tools: Optional[Iterable[str]] = None, + ) -> None: + # Convert string to enum if needed + if isinstance(field, str): + try: + field = IdentityFieldType(field) + except ValueError: + raise ValueError( + f"Field must be one of: {', '.join(f.value for f in IdentityFieldType)}" + ) + + self.field = field + self.confidence = confidence + self.concluded_value = concludedValue + self.methods = methods or [] # type:ignore[assignment] + self.tools = tools or [] # type:ignore[assignment] + + @property + @serializable.xml_attribute() + def field(self) -> str: + return ( + self._field.value + if isinstance(self._field, IdentityFieldType) + else self._field + ) + + @field.setter + def field(self, field: Union[IdentityFieldType, str]) -> None: + self._field = field + + @property + @serializable.xml_attribute() + def confidence(self) -> int: + return self._confidence + + @confidence.setter + def confidence(self, confidence: int) -> None: + if not 0 <= confidence <= 1: + raise ValueError("Confidence must be between 0 and 1") + self._confidence = confidence + + @property + @serializable.xml_array( + serializable.XmlArraySerializationType.FLAT, "concludedValue" + ) + def concluded_value(self) -> Optional[str]: + return self._concluded_value + + @concluded_value.setter + def concluded_value(self, concluded_value: Optional[str]) -> None: + self._concluded_value = concluded_value + + @property + @serializable.xml_array(serializable.XmlArraySerializationType.FLAT, "method") + def methods(self) -> "SortedSet[Method]": # Updated return type + return self._methods + + @methods.setter + def methods(self, methods: Iterable[Method]) -> None: # Updated parameter type + self._methods = SortedSet(methods) + + @property + @serializable.xml_array(serializable.XmlArraySerializationType.FLAT, "tool") + def tools(self) -> "SortedSet[str]": + return self._tools + + @tools.setter + def tools(self, tools: Iterable[str]) -> None: + self._tools = SortedSet(tools) + + def __comparable_tuple(self) -> _ComparableTuple: + return _ComparableTuple( + ( + self.field, + self.confidence, + self.concluded_value, + _ComparableTuple(self.methods), + _ComparableTuple(self.tools), + ) + ) + + def __eq__(self, other: object) -> bool: + if isinstance(other, Identity): + return self.__comparable_tuple() == other.__comparable_tuple() + return False + + def __lt__(self, other: Any) -> bool: + if isinstance(other, Identity): + 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 +class Occurrence: + """ + Our internal representation of the `occurrenceType` complex type. + + .. note:: + See the CycloneDX Schema definition: https://cyclonedx.org/docs/1.6/json/#components_items_evidence_occurrences + """ + + def __init__( + self, + *, + bom_ref: Optional[str] = None, + location: str, + line: Optional[int] = None, + offset: Optional[str] = None, + symbol: Optional[str] = None, + additional_context: Optional[str] = None, + ) -> None: + if location is None: + raise TypeError("location is required and cannot be None") + self.bom_ref = bom_ref + self.location = location + self.line = line + self.offset = offset + self.symbol = symbol + self.additional_context = additional_context + + @property + @serializable.json_name("bom-ref") + @serializable.xml_attribute() + def bom_ref(self) -> Optional[str]: + """ + Reference to a component defined in the BOM. + """ + return self._bom_ref + + @bom_ref.setter + def bom_ref(self, bom_ref: Optional[str]) -> None: + self._bom_ref = bom_ref + + @property + @serializable.xml_attribute() + def location(self) -> str: + """ + Location can be a file path, URL, or a unique identifier from a component discovery tool + """ + return self._location + + @location.setter + def location(self, location: str) -> None: + self._location = location + + @property + @serializable.xml_attribute() + def line(self) -> Optional[int]: + """ + The line number in the file where the dependency or reference was detected. + """ + return self._line + + @line.setter + def line(self, line: Optional[int]) -> None: + self._line = line + + @property + @serializable.xml_attribute() + def offset(self) -> Optional[str]: + """ + The offset location within the file where the dependency or reference was detected. + """ + return self._offset + + @offset.setter + def offset(self, offset: Optional[str]) -> None: + self._offset = offset + + @property + @serializable.xml_attribute() + def symbol(self) -> Optional[str]: + """ + Programming language symbol or import name. + """ + return self._symbol + + @symbol.setter + def symbol(self, symbol: Optional[str]) -> None: + self._symbol = symbol + + @property + @serializable.json_name("additionalContext") + @serializable.xml_attribute() + def additional_context(self) -> Optional[str]: + """ + Additional context about the occurrence of the component. + """ + return self._additional_context + + @additional_context.setter + def additional_context(self, additional_context: Optional[str]) -> None: + self._additional_context = additional_context + + def __comparable_tuple(self) -> _ComparableTuple: + return _ComparableTuple( + ( + self.bom_ref, + self.location, + self.line, + self.offset, + self.symbol, + self.additional_context, + ) + ) + + def __eq__(self, other: object) -> bool: + if isinstance(other, Occurrence): + return self.__comparable_tuple() == other.__comparable_tuple() + return False + + def __lt__(self, other: Any) -> bool: + if isinstance(other, Occurrence): + 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 +class StackFrame: + """ + Represents an individual frame in a call stack. + + .. note:: + See the CycloneDX Schema definition: https://cyclonedx.org/docs/1.6/json/#components_items_evidence_callstack + """ + + def __init__( + self, + *, + package: Optional[str] = None, + module: str, # module is required + function: Optional[str] = None, + parameters: Optional[Iterable[str]] = None, + line: Optional[int] = None, + column: Optional[int] = None, + full_filename: Optional[str] = None, + ) -> None: + if module is None: + raise TypeError("module is required and cannot be None") + + self.package = package + self.module = module + self.function = function + self.parameters = parameters or [] # type:ignore[assignment] + self.line = line + self.column = column + self.full_filename = full_filename + + @property + @serializable.xml_attribute() + def package(self) -> str: + """The package name""" + return self._package + + @package.setter + def package(self, package: str) -> None: + self._package = package + + @property + @serializable.xml_attribute() + def module(self) -> str: + """The module name""" + return self._module + + @module.setter + def module(self, module: str) -> None: + self._module = module + + @property + @serializable.xml_attribute() + def function(self) -> str: + """The function name""" + return self._function + + @function.setter + def function(self, function: str) -> None: + self._function = function + + @property + @serializable.json_name("parameters") + @serializable.xml_array(serializable.XmlArraySerializationType.FLAT, "parameters") + def parameters(self) -> "SortedSet[str]": + """Function parameters""" + return self._parameters + + @parameters.setter + def parameters(self, parameters: Iterable[str]) -> None: + self._parameters = SortedSet(parameters) + + @property + @serializable.xml_attribute() + def line(self) -> Optional[int]: + """The line number""" + return self._line + + @line.setter + def line(self, line: Optional[int]) -> None: + self._line = line + + @property + @serializable.xml_attribute() + def column(self) -> Optional[int]: + """The column number""" + return self._column + + @column.setter + def column(self, column: Optional[int]) -> None: + self._column = column + + @property + @serializable.json_name("fullFilename") + @serializable.xml_attribute() + def full_filename(self) -> Optional[str]: + """The full file path""" + return self._full_filename + + @full_filename.setter + def full_filename(self, full_filename: Optional[str]) -> None: + self._full_filename = full_filename + + def __comparable_tuple(self) -> _ComparableTuple: + return _ComparableTuple( + ( + self.package, + self.module, + self.function, + _ComparableTuple(self.parameters), + self.line, + self.column, + self.full_filename, + ) + ) + + def __eq__(self, other: object) -> bool: + if isinstance(other, StackFrame): + return self.__comparable_tuple() == other.__comparable_tuple() + return False + + def __lt__(self, other: Any) -> bool: + if isinstance(other, StackFrame): + 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 +class CallStack: + """ + Our internal representation of the `callStackType` complex type. + Contains an array of stack frames describing a call stack from when a component was identified. + + .. note:: + See the CycloneDX Schema definition: https://cyclonedx.org/docs/1.6/json/#components_items_evidence_callstack + """ + + def __init__( + self, + *, + frames: Optional[Iterable[StackFrame]] = None, + ) -> None: + self.frames = frames or [] # type:ignore[assignment] + + @property + @serializable.xml_array(serializable.XmlArraySerializationType.NESTED, "frames") + def frames(self) -> "SortedSet[StackFrame]": + """Array of stack frames""" + return self._frames + + @frames.setter + def frames(self, frames: Iterable[StackFrame]) -> None: + self._frames = SortedSet(frames) + + def __comparable_tuple(self) -> _ComparableTuple: + return _ComparableTuple((_ComparableTuple(self.frames))) + + def __eq__(self, other: object) -> bool: + if isinstance(other, CallStack): + return self.__comparable_tuple() == other.__comparable_tuple() + return False + + def __hash__(self) -> int: + return hash(self.__comparable_tuple()) + + def __repr__(self) -> str: + return f"" @serializable.serializable_class @@ -203,45 +715,63 @@ class ComponentEvidence: """ def __init__( - self, *, + self, + *, + identity: Optional[Iterable[Identity]] = None, + occurrences: Optional[Iterable[Occurrence]] = None, + callstack: Optional[CallStack] = None, licenses: Optional[Iterable[License]] = None, copyright: Optional[Iterable[Copyright]] = None, ) -> None: + self.identity = identity or [] # type:ignore[assignment] + self.occurrences = occurrences or [] # type:ignore[assignment] + self.callstack = callstack self.licenses = licenses or [] # type:ignore[assignment] self.copyright = copyright or [] # type:ignore[assignment] - # @property - # ... - # @serializable.view(SchemaVersion1Dot5) - # @serializable.xml_sequence(1) - # def identity(self) -> ...: - # ... # TODO since CDX1.5 - # - # @identity.setter - # def identity(self, ...) -> None: - # ... # TODO since CDX1.5 + @property + @serializable.view(SchemaVersion1Dot5) + @serializable.view(SchemaVersion1Dot6) + @serializable.xml_array(serializable.XmlArraySerializationType.NESTED, "identity") + @serializable.xml_sequence(1) + def identity(self) -> "SortedSet[Identity]": + """ + Provides a way to identify components via various methods. + """ + return self._identity - # @property - # ... - # @serializable.view(SchemaVersion1Dot5) - # @serializable.xml_sequence(2) - # def occurrences(self) -> ...: - # ... # TODO since CDX1.5 - # - # @occurrences.setter - # def occurrences(self, ...) -> None: - # ... # TODO since CDX1.5 + @identity.setter + def identity(self, identity: Iterable[Identity]) -> None: + self._identity = SortedSet(identity) - # @property - # ... - # @serializable.view(SchemaVersion1Dot5) - # @serializable.xml_sequence(3) - # def callstack(self) -> ...: - # ... # TODO since CDX1.5 - # - # @callstack.setter - # def callstack(self, ...) -> None: - # ... # TODO since CDX1.5 + @property + @serializable.view(SchemaVersion1Dot5) + @serializable.view(SchemaVersion1Dot6) + @serializable.xml_array(serializable.XmlArraySerializationType.NESTED, "occurrence") + @serializable.xml_sequence(2) + def occurrences(self) -> "SortedSet[Occurrence]": + """ + A list of locations where evidence was obtained from. + """ + return self._occurrences + + @occurrences.setter + def occurrences(self, occurrences: Iterable[Occurrence]) -> None: + self._occurrences = SortedSet(occurrences) + + @property + @serializable.view(SchemaVersion1Dot5) + @serializable.view(SchemaVersion1Dot6) + @serializable.xml_sequence(3) + def callstack(self) -> Optional[CallStack]: + """ + A representation of a call stack from when the component was identified. + """ + return self._callstack + + @callstack.setter + def callstack(self, callstack: Optional[CallStack]) -> None: + self._callstack = callstack @property @serializable.type_mapping(_LicenseRepositorySerializationHelper) @@ -260,9 +790,9 @@ def licenses(self, licenses: Iterable[License]) -> None: self._licenses = LicenseRepository(licenses) @property - @serializable.xml_array(serializable.XmlArraySerializationType.NESTED, 'text') + @serializable.xml_array(serializable.XmlArraySerializationType.NESTED, "text") @serializable.xml_sequence(5) - def copyright(self) -> 'SortedSet[Copyright]': + def copyright(self) -> "SortedSet[Copyright]": """ Optional list of copyright statements. @@ -276,10 +806,15 @@ def copyright(self, copyright: Iterable[Copyright]) -> None: self._copyright = SortedSet(copyright) def __comparable_tuple(self) -> _ComparableTuple: - return _ComparableTuple(( - _ComparableTuple(self.licenses), - _ComparableTuple(self.copyright), - )) + return _ComparableTuple( + ( + _ComparableTuple(self.identity), + _ComparableTuple(self.occurrences), + self.callstack, + _ComparableTuple(self.licenses), + _ComparableTuple(self.copyright), + ) + ) def __eq__(self, other: object) -> bool: if isinstance(other, ComponentEvidence): @@ -290,7 +825,7 @@ def __hash__(self) -> int: return hash(self.__comparable_tuple()) def __repr__(self) -> str: - return f'' + return f"" @serializable.serializable_enum diff --git a/tests/test_model_component.py b/tests/test_model_component.py index 05cf278c..bf4b657b 100644 --- a/tests/test_model_component.py +++ b/tests/test_model_component.py @@ -29,14 +29,20 @@ XsUri, ) from cyclonedx.model.component import ( + AnalysisTechnique, + CallStack, Commit, Component, ComponentEvidence, ComponentType, Diff, + Identity, + Method, + Occurrence, Patch, PatchClassification, Pedigree, + StackFrame, ) from cyclonedx.model.issue import IssueClassification, IssueType from tests import reorder @@ -290,6 +296,200 @@ class TestModelComponentEvidence(TestCase): def test_no_params(self) -> None: ComponentEvidence() # Does not raise `NoPropertiesProvidedException` + def test_identity(self) -> None: + identity = Identity(field="name", confidence=1, concludedValue="test") + ce = ComponentEvidence(identity=[identity]) + self.assertEqual(len(ce.identity), 1) + self.assertEqual(ce.identity.pop().field, "name") + + def test_identity_multiple(self) -> None: + identities = [ + Identity(field="name", confidence=1, concludedValue="test"), + Identity(field="version", confidence=0.8, concludedValue="1.0.0") + ] + ce = ComponentEvidence(identity=identities) + self.assertEqual(len(ce.identity), 2) + + def test_identity_with_methods(self) -> None: + """Test identity with analysis methods""" + methods = [ + Method( + technique=AnalysisTechnique.BINARY_ANALYSIS, # Changed order to test sorting + confidence=0.9, + value="Found in binary" + ), + Method( + technique=AnalysisTechnique.SOURCE_CODE_ANALYSIS, + confidence=0.8, + value="Found in source" + ) + ] + identity = Identity(field="name", confidence=1, methods=methods) + self.assertEqual(len(identity.methods), 2) + sorted_methods = sorted(methods) # Methods should be sorted by technique name + self.assertEqual(list(identity.methods), sorted_methods) + + # Verify first method + method = sorted_methods[0] + self.assertEqual(method.technique, AnalysisTechnique.BINARY_ANALYSIS) + self.assertEqual(method.confidence, 0.9) + self.assertEqual(method.value, "Found in binary") + + def test_method_sorting(self) -> None: + """Test that methods are properly sorted by technique value""" + methods = [ + Method(technique=AnalysisTechnique.SOURCE_CODE_ANALYSIS, confidence=0.8), + Method(technique=AnalysisTechnique.BINARY_ANALYSIS, confidence=0.9), + Method(technique=AnalysisTechnique.ATTESTATION, confidence=1.0) + ] + + sorted_methods = sorted(methods) + self.assertEqual(sorted_methods[0].technique, AnalysisTechnique.ATTESTATION) + self.assertEqual(sorted_methods[1].technique, AnalysisTechnique.BINARY_ANALYSIS) + self.assertEqual(sorted_methods[2].technique, AnalysisTechnique.SOURCE_CODE_ANALYSIS) + + def test_invalid_method_technique(self) -> None: + """Test that invalid technique raises ValueError""" + with self.assertRaises(ValueError): + Method(technique="invalid", confidence=0.5) + + def test_invalid_method_confidence(self) -> None: + """Test that invalid confidence raises ValueError""" + with self.assertRaises(ValueError): + Method(technique=AnalysisTechnique.FILENAME, confidence=1.5) + + def test_occurrences(self) -> None: + occurrence = Occurrence(location="/path/to/file", line=42) + ce = ComponentEvidence(occurrences=[occurrence]) + self.assertEqual(len(ce.occurrences), 1) + self.assertEqual(ce.occurrences.pop().line, 42) + + def test_stackframe(self) -> None: + # Test StackFrame with required fields + frame = StackFrame( + package="com.example", + module="app", + function="main", + parameters=["arg1", "arg2"], + line=1, + column=10, + full_filename="/path/to/file.py" + ) + self.assertEqual(frame.package, "com.example") + self.assertEqual(frame.module, "app") + self.assertEqual(frame.function, "main") + self.assertEqual(len(frame.parameters), 2) + self.assertEqual(frame.line, 1) + self.assertEqual(frame.column, 10) + self.assertEqual(frame.full_filename, "/path/to/file.py") + + def test_stackframe_module_required(self) -> None: + """Test that module is the only required field""" + frame = StackFrame(module="app") # Only mandatory field + self.assertEqual(frame.module, "app") + self.assertIsNone(frame.package) + self.assertIsNone(frame.function) + self.assertEqual(len(frame.parameters), 0) + self.assertIsNone(frame.line) + self.assertIsNone(frame.column) + self.assertIsNone(frame.full_filename) + + def test_stackframe_without_module(self) -> None: + """Test that omitting module raises TypeError""" + with self.assertRaises(TypeError): + StackFrame() # Should raise TypeError for missing module + + with self.assertRaises(TypeError): + StackFrame(package="com.example") # Should raise TypeError for missing module + + def test_stackframe_with_none_module(self) -> None: + """Test that setting module as None raises TypeError""" + with self.assertRaises(TypeError): + StackFrame(module=None) # Should raise TypeError for None module + + def test_callstack(self) -> None: + frame = StackFrame( + package="com.example", + module="app", + function="main" + ) + stack = CallStack(frames=[frame]) + ce = ComponentEvidence(callstack=stack) + self.assertIsNotNone(ce.callstack) + self.assertEqual(len(ce.callstack.frames), 1) + + def test_licenses(self) -> None: + from cyclonedx.model.license import DisjunctiveLicense + license = DisjunctiveLicense(id="MIT") + ce = ComponentEvidence(licenses=[license]) + self.assertEqual(len(ce.licenses), 1) + + def test_copyright(self) -> None: + copyright = Copyright(text="(c) 2023") + ce = ComponentEvidence(copyright=[copyright]) + self.assertEqual(len(ce.copyright), 1) + self.assertEqual(ce.copyright.pop().text, "(c) 2023") + + def test_full_evidence(self) -> None: + # Test with all fields populated + identity = Identity(field="name", confidence=1, concludedValue="test") + occurrence = Occurrence(location="/path/to/file", line=42) + frame = StackFrame(module="app", function="main", line=1) + stack = CallStack(frames=[frame]) + from cyclonedx.model.license import DisjunctiveLicense + license = DisjunctiveLicense(id="MIT") + copyright = Copyright(text="(c) 2023") + + ce = ComponentEvidence( + identity=[identity], + occurrences=[occurrence], + callstack=stack, + licenses=[license], + copyright=[copyright] + ) + + self.assertEqual(len(ce.identity), 1) + self.assertEqual(len(ce.occurrences), 1) + self.assertIsNotNone(ce.callstack) + self.assertEqual(len(ce.callstack.frames), 1) + self.assertEqual(len(ce.licenses), 1) + self.assertEqual(len(ce.copyright), 1) + + def test_full_evidence_with_complete_stack(self) -> None: + identity = Identity(field="name", confidence=1, concludedValue="test") + occurrence = Occurrence(location="/path/to/file", line=42) + + frame = StackFrame( + package="com.example", + module="app", + function="main", + parameters=["arg1", "arg2"], + line=1, + column=10, + full_filename="/path/to/file.py" + ) + stack = CallStack(frames=[frame]) + + from cyclonedx.model.license import DisjunctiveLicense + license = DisjunctiveLicense(id="MIT") + copyright = Copyright(text="(c) 2023") + + ce = ComponentEvidence( + identity=[identity], + occurrences=[occurrence], + callstack=stack, + licenses=[license], + copyright=[copyright] + ) + + self.assertEqual(len(ce.identity), 1) + self.assertEqual(len(ce.occurrences), 1) + self.assertIsNotNone(ce.callstack) + self.assertEqual(len(ce.callstack.frames), 1) + self.assertEqual(ce.callstack.frames.pop().package, "com.example") + self.assertEqual(len(ce.licenses), 1) + self.assertEqual(len(ce.copyright), 1) + def test_same_1(self) -> None: ce_1 = ComponentEvidence(copyright=[Copyright(text='Commercial')]) ce_2 = ComponentEvidence(copyright=[Copyright(text='Commercial')])