Skip to content

Commit 4b0f25c

Browse files
saquibsaifeeSaquib Saifee
authored andcommitted
Merge branch 'main' into perf-optimize-nested-components-8794458850882209436
2 parents c758f7e + 206e593 commit 4b0f25c

5 files changed

Lines changed: 63 additions & 20 deletions

File tree

‎.github/workflows/zizmor.yml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ jobs:
3232
persist-credentials: false
3333
- name: Run zizmor 🌈
3434
# see https://github.com/zizmorcore/zizmor-action
35-
uses: zizmorcore/zizmor-action@3dc1ecc9bcb9e94e9b2c709687979e1298497054 # v0.6.2
35+
uses: zizmorcore/zizmor-action@70fb788f84895a7701f5643d103d587e460b5c99 # v0.6.3
3636
with:
3737
# advanced-security: false => emit findings as workflow-command annotations (::error file=…) rather than
3838
# uploading a SARIF report to GitHub's Security tab.

‎cyclonedx/model/bom.py‎

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -708,9 +708,10 @@ def get_component_by_purl(self, purl: Optional['PackageURL']) -> Optional[Compon
708708
.. deprecated:: next
709709
"""
710710
if purl:
711-
found = [x for x in self.components if x.purl == purl]
712-
if len(found) == 1:
713-
return found[0]
711+
gen = (x for x in self.components if x.purl == purl)
712+
first = next(gen, None)
713+
if first and next(gen, None) is None:
714+
return first
714715

715716
return None
716717

@@ -742,9 +743,9 @@ def has_component(self, component: Component) -> bool:
742743

743744
def _get_all_components(self) -> Generator[Component, None, None]:
744745
if self.metadata.component:
745-
yield from self.metadata.component.get_all_nested_components(include_self=True)
746+
yield from self.metadata.component.iter_all_nested_components(include_self=True)
746747
for c in self.components:
747-
yield from c.get_all_nested_components(include_self=True)
748+
yield from c.iter_all_nested_components(include_self=True)
748749

749750
def get_vulnerabilities_for_bom_ref(self, bom_ref: BomRef) -> 'SortedSet[Vulnerability]':
750751
"""
@@ -855,8 +856,8 @@ def validate(self) -> bool:
855856
elem: Union[BomMetaData, Component, Service]
856857
for elem in chain( # type:ignore[assignment]
857858
[self.metadata],
858-
self.metadata.component.get_all_nested_components(include_self=True) if self.metadata.component else [],
859-
chain.from_iterable(c.get_all_nested_components(include_self=True) for c in self.components),
859+
self.metadata.component.iter_all_nested_components(include_self=True) if self.metadata.component else [],
860+
chain.from_iterable(c.iter_all_nested_components(include_self=True) for c in self.components),
860861
self.services
861862
):
862863
if len(elem.licenses) > 1 and any(isinstance(li, LicenseExpression) for li in elem.licenses):

‎cyclonedx/model/component.py‎

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717

1818
import re
1919
import sys
20-
from collections.abc import Iterable
20+
from collections.abc import Iterable, Iterator
2121
from enum import Enum
2222
from typing import Any, Optional, Union
2323
from warnings import warn
@@ -1684,19 +1684,29 @@ def tags(self) -> 'SortedSet[str]':
16841684
def tags(self, tags: Iterable[str]) -> None:
16851685
self._tags = SortedSet(tags)
16861686

1687-
def get_all_nested_components(self, include_self: bool = False) -> set['Component']:
1688-
components = set()
1689-
if include_self:
1690-
components.add(self)
1687+
def iter_all_nested_components(self, include_self: bool = False) -> Iterator['Component']:
1688+
"""
1689+
Iterate over this component and all its nested components (assemblies), recursively.
1690+
1691+
Components are deduplicated by object identity — NOT by equality.
1692+
This never calls the (costly) ``Component.__hash__``/``__eq__``,
1693+
which makes it dramatically faster than :func:`get_all_nested_components`
1694+
for large component trees. Safe against cyclic component graphs.
16911695
1692-
stack = list(self.components)
1696+
Order of iteration is not guaranteed.
1697+
"""
1698+
seen: dict[int, 'Component'] = {}
1699+
stack: list['Component'] = [self] if include_self else list(self.components)
16931700
while stack:
16941701
current = stack.pop()
1695-
if current not in components:
1696-
components.add(current)
1702+
current_id = id(current)
1703+
if current_id not in seen:
1704+
seen[current_id] = current
16971705
stack.extend(current.components)
1706+
return iter(seen.values())
16981707

1699-
return components
1708+
def get_all_nested_components(self, include_self: bool = False) -> set['Component']:
1709+
return set(self.iter_all_nested_components(include_self=include_self))
17001710

17011711
def get_pypi_url(self) -> str:
17021712
if self.version:

‎tests/_data/snapshots/README.md‎

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,31 @@
1-
# TEST FIXTURES
1+
# Test fixtures
22

3-
## RE-CREATION
3+
## Model fixtures
4+
5+
Serialization and deserialization tests share BOM factories from
6+
[`tests/_data/models.py`](../models.py). To cover a model change:
7+
8+
1. Add a descriptively named factory that starts with `get_bom_` and returns a
9+
`Bom`. The function is discovered automatically; its name also becomes the
10+
snapshot filename prefix.
11+
2. Populate every field relevant to the change so the JSON and XML output
12+
tests exercise the complete model. Name a fixture ending in `_invalid` only
13+
when serialization is expected to fail.
14+
3. Register a factory in `all_get_bom_funct_with_incomplete_deps` when its
15+
dependency graph is intentionally incomplete. Otherwise, include the
16+
complete dependency graph in the returned BOM.
17+
4. Re-create the snapshots, review the changed files, then run the tests again
18+
without snapshot re-creation enabled.
19+
5. For version-specific fixtures, prefix the name with the target schema version
20+
tag (e.g. `get_bom_v1_5_*`). The test runner uses `is_valid_for_schema_version()`
21+
to ensure such models are only evaluated against applicable schema versions.
22+
23+
The output tests serialize each discovered model for the applicable schema
24+
versions, validate the generated JSON and XML, and compare it with the stored
25+
snapshots. The deserialization tests also use these fixtures for round-trip
26+
coverage.
27+
28+
## Re-creation
429

530
Some assets here can be (re-)created automatically, by setting the env var `CDX_TEST_RECREATE_SNAPSHOTS=1`.
631
It might also help to set `PYTHONHASHSEED=0`!
@@ -10,4 +35,6 @@ As a shortcut just run:
1035
CDX_TEST_RECREATE_SNAPSHOTS=1 poetry run tox -e py
1136
```
1237

13-
The files will be written as is, which might not be human-readable. feel free to reformat the files manually.
38+
Only commit snapshots that belong to the model change. Snapshot tests compare
39+
the serialized text with the stored fixture using exact string equality, so do
40+
not reformat snapshots manually.

‎tests/test_model_component.py‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,7 @@ def test_empty_basic_component(self) -> None:
112112
self.assertIsNone(c.release_notes)
113113
self.assertEqual(len(c.components), 0)
114114
self.assertEqual(len(c.get_all_nested_components(include_self=True)), 1)
115+
self.assertEqual(len(list(c.iter_all_nested_components(include_self=True))), 1)
115116

116117
def test_multiple_basic_components(self) -> None:
117118
c1 = Component(name='test-component')
@@ -312,6 +313,8 @@ def test_nested_components_1(self) -> None:
312313
self.assertEqual(1, len(comp_b.components))
313314
self.assertEqual(2, len(comp_b.get_all_nested_components(include_self=True)))
314315
self.assertEqual(1, len(comp_b.get_all_nested_components(include_self=False)))
316+
self.assertEqual(2, len(list(comp_b.iter_all_nested_components(include_self=True))))
317+
self.assertEqual(1, len(list(comp_b.iter_all_nested_components(include_self=False))))
315318

316319
def test_nested_components_2(self) -> None:
317320
comp_a = Component(name='comp_a')
@@ -323,6 +326,8 @@ def test_nested_components_2(self) -> None:
323326
self.assertEqual(2, len(comp_b.components))
324327
self.assertEqual(3, len(comp_b.get_all_nested_components(include_self=True)))
325328
self.assertEqual(2, len(comp_b.get_all_nested_components(include_self=False)))
329+
self.assertEqual(3, len(list(comp_b.iter_all_nested_components(include_self=True))))
330+
self.assertEqual(2, len(list(comp_b.iter_all_nested_components(include_self=False))))
326331

327332

328333
class TestModelDiff(TestCase):

0 commit comments

Comments
 (0)