From 1480a09828900a9407c2488ccdf145df71eca098 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 | 29 +++++++++- 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 | 39 +++++++++++++ 18 files changed, 443 insertions(+), 2 deletions(-) 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..518ef8248 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) @@ -1683,7 +1708,7 @@ def __comparable_tuple(self) -> _ComparableTuple: self.swid, self.cpe, _ComparableTuple(self.swhids), self.supplier, self.author, self.publisher, self.description, - self.mime_type, self.scope, _ComparableTuple(self.hashes), + self.mime_type, self.scope, self.is_external, _ComparableTuple(self.hashes), _ComparableTuple(self.licenses), self.copyright, self.pedigree, _ComparableTuple(self.external_references), _ComparableTuple(self.properties), 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 55a5cdb9a..edd9f8157 100644 --- a/tests/_data/models.py +++ b/tests/_data/models.py @@ -593,6 +593,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'), @@ -853,6 +858,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', @@ -1611,6 +1626,7 @@ def get_bom_for_issue540_duplicate_components() -> 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..ca37e59e9 100644 --- a/tests/test_model_component.py +++ b/tests/test_model_component.py @@ -264,6 +264,45 @@ 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: + # expected sort order: (type, group, name, version, is_external) + # 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')