diff --git a/cyclonedx/model/bom.py b/cyclonedx/model/bom.py index dec1d96a..3f76c94a 100644 --- a/cyclonedx/model/bom.py +++ b/cyclonedx/model/bom.py @@ -743,9 +743,9 @@ def has_component(self, component: Component) -> bool: def _get_all_components(self) -> Generator[Component, None, None]: if self.metadata.component: - yield from self.metadata.component.get_all_nested_components(include_self=True) + yield from self.metadata.component.iter_all_nested_components(include_self=True) for c in self.components: - yield from c.get_all_nested_components(include_self=True) + yield from c.iter_all_nested_components(include_self=True) def get_vulnerabilities_for_bom_ref(self, bom_ref: BomRef) -> 'SortedSet[Vulnerability]': """ @@ -856,8 +856,8 @@ def validate(self) -> bool: elem: Union[BomMetaData, Component, Service] for elem in chain( # type:ignore[assignment] [self.metadata], - self.metadata.component.get_all_nested_components(include_self=True) if self.metadata.component else [], - chain.from_iterable(c.get_all_nested_components(include_self=True) for c in self.components), + self.metadata.component.iter_all_nested_components(include_self=True) if self.metadata.component else [], + chain.from_iterable(c.iter_all_nested_components(include_self=True) for c in self.components), self.services ): if len(elem.licenses) > 1 and any(isinstance(li, LicenseExpression) for li in elem.licenses): diff --git a/cyclonedx/model/component.py b/cyclonedx/model/component.py index bc7152ae..a297e4ef 100644 --- a/cyclonedx/model/component.py +++ b/cyclonedx/model/component.py @@ -17,7 +17,7 @@ import re import sys -from collections.abc import Iterable +from collections.abc import Iterable, Iterator from enum import Enum from typing import Any, Optional, Union from warnings import warn @@ -1684,15 +1684,29 @@ def tags(self) -> 'SortedSet[str]': def tags(self, tags: Iterable[str]) -> None: self._tags = SortedSet(tags) - def get_all_nested_components(self, include_self: bool = False) -> set['Component']: - components = set() - if include_self: - components.add(self) + def iter_all_nested_components(self, include_self: bool = False) -> Iterator['Component']: + """ + Iterate over this component and all its nested components (assemblies), recursively. - for c in self.components: - components.update(c.get_all_nested_components(include_self=True)) + Components are deduplicated by object identity — NOT by equality. + This never calls the (costly) ``Component.__hash__``/``__eq__``, + which makes it dramatically faster than :func:`get_all_nested_components` + for large component trees. Safe against cyclic component graphs. - return components + Order of iteration is not guaranteed. + """ + seen: dict[int, 'Component'] = {} + stack: list['Component'] = [self] if include_self else list(self.components) + while stack: + current = stack.pop() + current_id = id(current) + if current_id not in seen: + seen[current_id] = current + stack.extend(current.components) + return iter(seen.values()) + + def get_all_nested_components(self, include_self: bool = False) -> set['Component']: + return set(self.iter_all_nested_components(include_self=include_self)) def get_pypi_url(self) -> str: if self.version: diff --git a/tests/test_model_component.py b/tests/test_model_component.py index f7b8fc80..dc7704d7 100644 --- a/tests/test_model_component.py +++ b/tests/test_model_component.py @@ -112,6 +112,7 @@ def test_empty_basic_component(self) -> None: self.assertIsNone(c.release_notes) self.assertEqual(len(c.components), 0) self.assertEqual(len(c.get_all_nested_components(include_self=True)), 1) + self.assertEqual(len(list(c.iter_all_nested_components(include_self=True))), 1) def test_multiple_basic_components(self) -> None: c1 = Component(name='test-component') @@ -312,6 +313,8 @@ def test_nested_components_1(self) -> None: self.assertEqual(1, len(comp_b.components)) self.assertEqual(2, len(comp_b.get_all_nested_components(include_self=True))) self.assertEqual(1, len(comp_b.get_all_nested_components(include_self=False))) + self.assertEqual(2, len(list(comp_b.iter_all_nested_components(include_self=True)))) + self.assertEqual(1, len(list(comp_b.iter_all_nested_components(include_self=False)))) def test_nested_components_2(self) -> None: comp_a = Component(name='comp_a') @@ -323,6 +326,8 @@ def test_nested_components_2(self) -> None: self.assertEqual(2, len(comp_b.components)) self.assertEqual(3, len(comp_b.get_all_nested_components(include_self=True))) self.assertEqual(2, len(comp_b.get_all_nested_components(include_self=False))) + self.assertEqual(3, len(list(comp_b.iter_all_nested_components(include_self=True)))) + self.assertEqual(2, len(list(comp_b.iter_all_nested_components(include_self=False)))) class TestModelDiff(TestCase):