From 61198062d8b530d81f2aa96e595c29f239d70c4a Mon Sep 17 00:00:00 2001 From: "Alexandr N Zamaraev (aka tonal)" Date: Sun, 12 Apr 2026 14:58:23 +0700 Subject: [PATCH] feat: add isExternal property to Component for CycloneDX v1.7 Implement the isExternal boolean property on Component as specified in CycloneDX v1.7 schema. An external component is one that is not part of an assembly, but is expected to be provided by the environment. - Add is_external property to Component class with XML attribute serialization - Create XmlBoolAttribute serialization helper for proper bool handling - Add unit tests for is_external (default value, set/get, equality, sorting) - Add test fixture and snapshots for JSON/XML output - Supports v1.7+ schemas only Implements: https://github.com/CycloneDX/cyclonedx-python-lib/issues/903 Co-authored-by: Qwen-Coder Signed-off-by: Alexandr N Zamaraev (aka tonal) --- cyclonedx/model/component.py | 28 ++++++++- cyclonedx/serialization/__init__.py | 57 +++++++++++++++++++ tests/_data/models.py | 16 ++++++ ...om_with_external_component_1_7-1.0.xml.bin | 11 ++++ ...om_with_external_component_1_7-1.1.xml.bin | 10 ++++ ...m_with_external_component_1_7-1.2.json.bin | 24 ++++++++ ...om_with_external_component_1_7-1.2.xml.bin | 16 ++++++ ...m_with_external_component_1_7-1.3.json.bin | 24 ++++++++ ...om_with_external_component_1_7-1.3.xml.bin | 16 ++++++ ...m_with_external_component_1_7-1.4.json.bin | 24 ++++++++ ...om_with_external_component_1_7-1.4.xml.bin | 16 ++++++ ...m_with_external_component_1_7-1.5.json.bin | 34 +++++++++++ ...om_with_external_component_1_7-1.5.xml.bin | 20 +++++++ ...m_with_external_component_1_7-1.6.json.bin | 34 +++++++++++ ...om_with_external_component_1_7-1.6.xml.bin | 20 +++++++ ...m_with_external_component_1_7-1.7.json.bin | 35 ++++++++++++ ...om_with_external_component_1_7-1.7.xml.bin | 20 +++++++ tests/test_model_component.py | 40 +++++++++++++ 18 files changed, 444 insertions(+), 1 deletion(-) create mode 100644 tests/_data/snapshots/get_bom_with_external_component_1_7-1.0.xml.bin create mode 100644 tests/_data/snapshots/get_bom_with_external_component_1_7-1.1.xml.bin create mode 100644 tests/_data/snapshots/get_bom_with_external_component_1_7-1.2.json.bin create mode 100644 tests/_data/snapshots/get_bom_with_external_component_1_7-1.2.xml.bin create mode 100644 tests/_data/snapshots/get_bom_with_external_component_1_7-1.3.json.bin create mode 100644 tests/_data/snapshots/get_bom_with_external_component_1_7-1.3.xml.bin create mode 100644 tests/_data/snapshots/get_bom_with_external_component_1_7-1.4.json.bin create mode 100644 tests/_data/snapshots/get_bom_with_external_component_1_7-1.4.xml.bin create mode 100644 tests/_data/snapshots/get_bom_with_external_component_1_7-1.5.json.bin create mode 100644 tests/_data/snapshots/get_bom_with_external_component_1_7-1.5.xml.bin create mode 100644 tests/_data/snapshots/get_bom_with_external_component_1_7-1.6.json.bin create mode 100644 tests/_data/snapshots/get_bom_with_external_component_1_7-1.6.xml.bin create mode 100644 tests/_data/snapshots/get_bom_with_external_component_1_7-1.7.json.bin create mode 100644 tests/_data/snapshots/get_bom_with_external_component_1_7-1.7.xml.bin diff --git a/cyclonedx/model/component.py b/cyclonedx/model/component.py index 09031eef7..bc7152aeb 100644 --- a/cyclonedx/model/component.py +++ b/cyclonedx/model/component.py @@ -51,7 +51,7 @@ SchemaVersion1Dot6, SchemaVersion1Dot7, ) -from ..serialization import PackageUrl as PackageUrlSH +from ..serialization import PackageUrl as PackageUrlSH, XmlBoolAttribute as _XmlBoolAttributeSH from . import ( AttachedText, ExternalReference, @@ -993,6 +993,7 @@ def __init__( version: Optional[str] = None, description: Optional[str] = None, scope: Optional[ComponentScope] = None, + is_external: Optional[bool] = None, hashes: Optional[Iterable[HashType]] = None, licenses: Optional[Iterable[License]] = None, copyright: Optional[str] = None, @@ -1026,6 +1027,7 @@ def __init__( self.name = name self.description = description self.scope = scope + self.is_external = is_external self.hashes = hashes or [] self.licenses = licenses or [] self.copyright = copyright @@ -1304,6 +1306,29 @@ def scope(self) -> Optional[ComponentScope]: def scope(self, scope: Optional[ComponentScope]) -> None: self._scope = scope + @property + @serializable.json_name('isExternal') + @serializable.xml_name('isExternal') + @serializable.xml_attribute() + @serializable.type_mapping(_XmlBoolAttributeSH) + @serializable.view(SchemaVersion1Dot7) + def is_external(self) -> Optional[bool]: + """ + Determine whether this component is external. An external component is one that is not part of an assembly, + but is expected to be provided by the environment, regardless of the component's scope. This setting can be + useful for distinguishing which components are bundled with the product and which can be relied upon to be + present in the deployment environment. This may be set to true for runtime components only. For + metadata.component, it must be set to false. + + Returns: + `bool` if set else `None` + """ + return self._is_external + + @is_external.setter + def is_external(self, is_external: Optional[bool]) -> None: + self._is_external = is_external + @property @serializable.type_mapping(_HashTypeRepositorySerializationHelper) @serializable.xml_sequence(11) @@ -1690,6 +1715,7 @@ def __comparable_tuple(self) -> _ComparableTuple: _ComparableTuple(self.components), self.evidence, self.release_notes, self.modified, _ComparableTuple(self.authors), _ComparableTuple(self.omnibor_ids), self.manufacturer, self.crypto_properties, _ComparableTuple(self.tags), + self.is_external, )) def __eq__(self, other: object) -> bool: diff --git a/cyclonedx/serialization/__init__.py b/cyclonedx/serialization/__init__.py index 1fec0026f..a5b946cbc 100644 --- a/cyclonedx/serialization/__init__.py +++ b/cyclonedx/serialization/__init__.py @@ -95,6 +95,63 @@ def deserialize(cls, o: Any) -> UUID: ) from err +class XmlBoolAttribute(BaseHelper): + """Helper for serializing boolean values as XML attribute-compatible 'true'/'false' strings, + while keeping native boolean values for JSON.""" + + @classmethod + def json_serialize(cls, o: Any) -> Optional[bool]: + if o is None: + return None + if isinstance(o, bool): + return o + raise SerializationOfUnexpectedValueException( + f'Attempt to serialize a non-boolean: {o!r}') + + @classmethod + def json_deserialize(cls, o: Any) -> Optional[bool]: + if o is None: + return None + if isinstance(o, bool): + return o + raise CycloneDxDeserializationException( + f'Invalid boolean value: {o!r}' + ) + + @classmethod + def xml_serialize(cls, o: Any) -> Optional[str]: + if o is None: + return None + if isinstance(o, bool): + return 'true' if o else 'false' + raise SerializationOfUnexpectedValueException( + f'Attempt to serialize a non-boolean: {o!r}') + + @classmethod + def xml_deserialize(cls, o: Any) -> Optional[bool]: + if o is None: + return None + if isinstance(o, bool): + return o + if isinstance(o, str): + o_lower = o.lower() + if o_lower in ('1', 'true'): + return True + if o_lower in ('0', 'false'): + return False + raise CycloneDxDeserializationException( + f'Invalid boolean value: {o!r}' + ) + + @classmethod + def serialize(cls, o: Any) -> Any: + return cls.xml_serialize(o) + + @classmethod + def deserialize(cls, o: Any) -> Any: + return cls.xml_deserialize(o) + + @deprecated('No public API planned for replacing this,') class LicenseRepositoryHelper(_LicenseRepositorySerializationHelper): """**DEPRECATED** diff --git a/tests/_data/models.py b/tests/_data/models.py index 565f56ec9..e2052878f 100644 --- a/tests/_data/models.py +++ b/tests/_data/models.py @@ -599,6 +599,11 @@ def get_bom_with_external_references() -> Bom: return bom +def get_bom_with_external_component_1_7() -> Bom: + bom = _make_bom(components=[get_component_external()]) + return bom + + def get_bom_with_services_simple() -> Bom: bom = _make_bom(services=[ Service(name='my-first-service', bom_ref='my-specific-bom-ref-for-my-first-service'), @@ -859,6 +864,16 @@ def get_component_setuptools_simple( ) +def get_component_external() -> Component: + return Component( + name='external-lib', version='1.0.0', + type=ComponentType.LIBRARY, + is_external=True, + scope=ComponentScope.REQUIRED, + bom_ref='external-lib-1.0.0', + ) + + def get_component_setuptools_simple_no_version(bom_ref: Optional[str] = None) -> Component: return Component( name='setuptools', bom_ref=bom_ref or 'pkg:pypi/setuptools?extension=tar.gz', @@ -1703,6 +1718,7 @@ def get_bom_for_issue941_nested_dependencies_irreversible_migrate() -> Bom: get_bom_with_licenses, get_bom_with_multiple_licenses, get_bom_for_issue_497_urls, + get_bom_with_external_component_1_7, get_bom_for_issue_598_multiple_components_with_purl_qualifiers, get_bom_with_component_setuptools_with_v16_fields, get_bom_for_issue_630_empty_property, diff --git a/tests/_data/snapshots/get_bom_with_external_component_1_7-1.0.xml.bin b/tests/_data/snapshots/get_bom_with_external_component_1_7-1.0.xml.bin new file mode 100644 index 000000000..aaae83372 --- /dev/null +++ b/tests/_data/snapshots/get_bom_with_external_component_1_7-1.0.xml.bin @@ -0,0 +1,11 @@ + + + + + external-lib + 1.0.0 + required + false + + + diff --git a/tests/_data/snapshots/get_bom_with_external_component_1_7-1.1.xml.bin b/tests/_data/snapshots/get_bom_with_external_component_1_7-1.1.xml.bin new file mode 100644 index 000000000..06e044d33 --- /dev/null +++ b/tests/_data/snapshots/get_bom_with_external_component_1_7-1.1.xml.bin @@ -0,0 +1,10 @@ + + + + + external-lib + 1.0.0 + required + + + diff --git a/tests/_data/snapshots/get_bom_with_external_component_1_7-1.2.json.bin b/tests/_data/snapshots/get_bom_with_external_component_1_7-1.2.json.bin new file mode 100644 index 000000000..fe5a4e0ac --- /dev/null +++ b/tests/_data/snapshots/get_bom_with_external_component_1_7-1.2.json.bin @@ -0,0 +1,24 @@ +{ + "components": [ + { + "bom-ref": "external-lib-1.0.0", + "name": "external-lib", + "scope": "required", + "type": "library", + "version": "1.0.0" + } + ], + "dependencies": [ + { + "ref": "external-lib-1.0.0" + } + ], + "metadata": { + "timestamp": "2023-01-07T13:44:32.312678+00:00" + }, + "serialNumber": "urn:uuid:1441d33a-e0fc-45b5-af3b-61ee52a88bac", + "version": 1, + "$schema": "http://cyclonedx.org/schema/bom-1.2b.schema.json", + "bomFormat": "CycloneDX", + "specVersion": "1.2" +} \ No newline at end of file diff --git a/tests/_data/snapshots/get_bom_with_external_component_1_7-1.2.xml.bin b/tests/_data/snapshots/get_bom_with_external_component_1_7-1.2.xml.bin new file mode 100644 index 000000000..266020af7 --- /dev/null +++ b/tests/_data/snapshots/get_bom_with_external_component_1_7-1.2.xml.bin @@ -0,0 +1,16 @@ + + + + 2023-01-07T13:44:32.312678+00:00 + + + + external-lib + 1.0.0 + required + + + + + + diff --git a/tests/_data/snapshots/get_bom_with_external_component_1_7-1.3.json.bin b/tests/_data/snapshots/get_bom_with_external_component_1_7-1.3.json.bin new file mode 100644 index 000000000..8500a9f79 --- /dev/null +++ b/tests/_data/snapshots/get_bom_with_external_component_1_7-1.3.json.bin @@ -0,0 +1,24 @@ +{ + "components": [ + { + "bom-ref": "external-lib-1.0.0", + "name": "external-lib", + "scope": "required", + "type": "library", + "version": "1.0.0" + } + ], + "dependencies": [ + { + "ref": "external-lib-1.0.0" + } + ], + "metadata": { + "timestamp": "2023-01-07T13:44:32.312678+00:00" + }, + "serialNumber": "urn:uuid:1441d33a-e0fc-45b5-af3b-61ee52a88bac", + "version": 1, + "$schema": "http://cyclonedx.org/schema/bom-1.3a.schema.json", + "bomFormat": "CycloneDX", + "specVersion": "1.3" +} \ No newline at end of file diff --git a/tests/_data/snapshots/get_bom_with_external_component_1_7-1.3.xml.bin b/tests/_data/snapshots/get_bom_with_external_component_1_7-1.3.xml.bin new file mode 100644 index 000000000..120d5d288 --- /dev/null +++ b/tests/_data/snapshots/get_bom_with_external_component_1_7-1.3.xml.bin @@ -0,0 +1,16 @@ + + + + 2023-01-07T13:44:32.312678+00:00 + + + + external-lib + 1.0.0 + required + + + + + + diff --git a/tests/_data/snapshots/get_bom_with_external_component_1_7-1.4.json.bin b/tests/_data/snapshots/get_bom_with_external_component_1_7-1.4.json.bin new file mode 100644 index 000000000..c2c3bbea0 --- /dev/null +++ b/tests/_data/snapshots/get_bom_with_external_component_1_7-1.4.json.bin @@ -0,0 +1,24 @@ +{ + "components": [ + { + "bom-ref": "external-lib-1.0.0", + "name": "external-lib", + "scope": "required", + "type": "library", + "version": "1.0.0" + } + ], + "dependencies": [ + { + "ref": "external-lib-1.0.0" + } + ], + "metadata": { + "timestamp": "2023-01-07T13:44:32.312678+00:00" + }, + "serialNumber": "urn:uuid:1441d33a-e0fc-45b5-af3b-61ee52a88bac", + "version": 1, + "$schema": "http://cyclonedx.org/schema/bom-1.4.schema.json", + "bomFormat": "CycloneDX", + "specVersion": "1.4" +} \ No newline at end of file diff --git a/tests/_data/snapshots/get_bom_with_external_component_1_7-1.4.xml.bin b/tests/_data/snapshots/get_bom_with_external_component_1_7-1.4.xml.bin new file mode 100644 index 000000000..2d85c7dec --- /dev/null +++ b/tests/_data/snapshots/get_bom_with_external_component_1_7-1.4.xml.bin @@ -0,0 +1,16 @@ + + + + 2023-01-07T13:44:32.312678+00:00 + + + + external-lib + 1.0.0 + required + + + + + + diff --git a/tests/_data/snapshots/get_bom_with_external_component_1_7-1.5.json.bin b/tests/_data/snapshots/get_bom_with_external_component_1_7-1.5.json.bin new file mode 100644 index 000000000..f3d168966 --- /dev/null +++ b/tests/_data/snapshots/get_bom_with_external_component_1_7-1.5.json.bin @@ -0,0 +1,34 @@ +{ + "components": [ + { + "bom-ref": "external-lib-1.0.0", + "name": "external-lib", + "scope": "required", + "type": "library", + "version": "1.0.0" + } + ], + "dependencies": [ + { + "ref": "external-lib-1.0.0" + } + ], + "metadata": { + "timestamp": "2023-01-07T13:44:32.312678+00:00" + }, + "properties": [ + { + "name": "key1", + "value": "val1" + }, + { + "name": "key2", + "value": "val2" + } + ], + "serialNumber": "urn:uuid:1441d33a-e0fc-45b5-af3b-61ee52a88bac", + "version": 1, + "$schema": "http://cyclonedx.org/schema/bom-1.5.schema.json", + "bomFormat": "CycloneDX", + "specVersion": "1.5" +} \ No newline at end of file diff --git a/tests/_data/snapshots/get_bom_with_external_component_1_7-1.5.xml.bin b/tests/_data/snapshots/get_bom_with_external_component_1_7-1.5.xml.bin new file mode 100644 index 000000000..f06f8a0b5 --- /dev/null +++ b/tests/_data/snapshots/get_bom_with_external_component_1_7-1.5.xml.bin @@ -0,0 +1,20 @@ + + + + 2023-01-07T13:44:32.312678+00:00 + + + + external-lib + 1.0.0 + required + + + + + + + val1 + val2 + + diff --git a/tests/_data/snapshots/get_bom_with_external_component_1_7-1.6.json.bin b/tests/_data/snapshots/get_bom_with_external_component_1_7-1.6.json.bin new file mode 100644 index 000000000..bf66e8484 --- /dev/null +++ b/tests/_data/snapshots/get_bom_with_external_component_1_7-1.6.json.bin @@ -0,0 +1,34 @@ +{ + "components": [ + { + "bom-ref": "external-lib-1.0.0", + "name": "external-lib", + "scope": "required", + "type": "library", + "version": "1.0.0" + } + ], + "dependencies": [ + { + "ref": "external-lib-1.0.0" + } + ], + "metadata": { + "timestamp": "2023-01-07T13:44:32.312678+00:00" + }, + "properties": [ + { + "name": "key1", + "value": "val1" + }, + { + "name": "key2", + "value": "val2" + } + ], + "serialNumber": "urn:uuid:1441d33a-e0fc-45b5-af3b-61ee52a88bac", + "version": 1, + "$schema": "http://cyclonedx.org/schema/bom-1.6.schema.json", + "bomFormat": "CycloneDX", + "specVersion": "1.6" +} \ No newline at end of file diff --git a/tests/_data/snapshots/get_bom_with_external_component_1_7-1.6.xml.bin b/tests/_data/snapshots/get_bom_with_external_component_1_7-1.6.xml.bin new file mode 100644 index 000000000..c81104a76 --- /dev/null +++ b/tests/_data/snapshots/get_bom_with_external_component_1_7-1.6.xml.bin @@ -0,0 +1,20 @@ + + + + 2023-01-07T13:44:32.312678+00:00 + + + + external-lib + 1.0.0 + required + + + + + + + val1 + val2 + + diff --git a/tests/_data/snapshots/get_bom_with_external_component_1_7-1.7.json.bin b/tests/_data/snapshots/get_bom_with_external_component_1_7-1.7.json.bin new file mode 100644 index 000000000..c3c2f85b3 --- /dev/null +++ b/tests/_data/snapshots/get_bom_with_external_component_1_7-1.7.json.bin @@ -0,0 +1,35 @@ +{ + "components": [ + { + "bom-ref": "external-lib-1.0.0", + "isExternal": true, + "name": "external-lib", + "scope": "required", + "type": "library", + "version": "1.0.0" + } + ], + "dependencies": [ + { + "ref": "external-lib-1.0.0" + } + ], + "metadata": { + "timestamp": "2023-01-07T13:44:32.312678+00:00" + }, + "properties": [ + { + "name": "key1", + "value": "val1" + }, + { + "name": "key2", + "value": "val2" + } + ], + "serialNumber": "urn:uuid:1441d33a-e0fc-45b5-af3b-61ee52a88bac", + "version": 1, + "$schema": "http://cyclonedx.org/schema/bom-1.7.schema.json", + "bomFormat": "CycloneDX", + "specVersion": "1.7" +} \ No newline at end of file diff --git a/tests/_data/snapshots/get_bom_with_external_component_1_7-1.7.xml.bin b/tests/_data/snapshots/get_bom_with_external_component_1_7-1.7.xml.bin new file mode 100644 index 000000000..b16d837f9 --- /dev/null +++ b/tests/_data/snapshots/get_bom_with_external_component_1_7-1.7.xml.bin @@ -0,0 +1,20 @@ + + + + 2023-01-07T13:44:32.312678+00:00 + + + + external-lib + 1.0.0 + required + + + + + + + val1 + val2 + + diff --git a/tests/test_model_component.py b/tests/test_model_component.py index 44f59a121..f7b8fc80a 100644 --- a/tests/test_model_component.py +++ b/tests/test_model_component.py @@ -264,6 +264,46 @@ def test_sort(self) -> None: expected_components = reorder(components, expected_order) self.assertListEqual(sorted_components, expected_components) + def test_is_external_default_value(self) -> None: + c = Component(name='test-component') + self.assertIsNone(c.is_external) + + def test_is_external_set_true(self) -> None: + c = Component(name='test-component', is_external=True) + self.assertTrue(c.is_external) + + def test_is_external_set_false(self) -> None: + c = Component(name='test-component', is_external=False) + self.assertFalse(c.is_external) + + def test_is_external_equality_same(self) -> None: + c1 = Component(name='test-component', is_external=True) + c2 = Component(name='test-component', is_external=True) + self.assertEqual(c1, c2) + + def test_is_external_equality_different(self) -> None: + c1 = Component(name='test-component', is_external=True) + c2 = Component(name='test-component', is_external=False) + c3 = Component(name='test-component') + self.assertNotEqual(c1, c2) + self.assertNotEqual(c1, c3) + self.assertNotEqual(c2, c3) + + def test_is_external_sorting(self) -> None: + # all comparable fields except is_external are equal across these components, + # so is_external (the last field in the comparable tuple) decides the order. + # ComparableTuple treats None as greater than any value, + # so order is: False < True < None + expected_order = [1, 0, 2] + components = [ + Component(name='component-a', is_external=True), + Component(name='component-a', is_external=False), + Component(name='component-a'), # is_external=None + ] + sorted_components = sorted(components) + expected_components = reorder(components, expected_order) + self.assertListEqual(sorted_components, expected_components) + def test_nested_components_1(self) -> None: comp_b = Component(name='comp_b') comp_c = Component(name='comp_c')