Skip to content

Commit 78b8d8b

Browse files
authored
feat: add contrib.bom.utils.BomDependencyGraphFlatMerger (#997)
Signed-off-by: Jan Kowalleck <jan.kowalleck@gmail.com>
1 parent 9beaf5c commit 78b8d8b

2 files changed

Lines changed: 216 additions & 2 deletions

File tree

cyclonedx/contrib/bom/utils.py

Lines changed: 73 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,13 +17,15 @@
1717

1818
"""Bom related utilities"""
1919

20-
__all__ = ['BomRefDiscriminator']
20+
__all__ = ['BomRefDiscriminator', 'BomDependencyGraphFlatMerger']
2121

2222
from collections.abc import Iterable
2323
from itertools import chain
2424
from random import random
2525
from typing import TYPE_CHECKING, Any
2626

27+
from ...model.dependency import Dependency
28+
2729
if TYPE_CHECKING: # pragma: no cover
2830
from ...model.bom import Bom
2931
from ...model.bom_ref import BomRef
@@ -96,3 +98,73 @@ def from_bom(cls, bom: 'Bom', prefix: str = 'BomRef') -> 'BomRefDiscriminator':
9698
map(lambda s: s.bom_ref, bom.services),
9799
map(lambda v: v.bom_ref, bom.vulnerabilities)
98100
), prefix)
101+
102+
103+
class BomDependencyGraphFlatMerger:
104+
"""
105+
Context‑manager utility that temporarily flattens and merges all
106+
:attr:`cyclonedx.model.bom.Bom.dependencies`.
107+
108+
When used as a context manager, the :class:`cyclonedx.model.bom.Bom`'s
109+
dependency graph is replaced with a flattened, merged representation
110+
for the duration of the ``with`` block and automatically restored
111+
afterward.
112+
"""
113+
114+
def __init__(self, bom: 'Bom') -> None:
115+
self._bom = bom
116+
# NOTE: do not use the getter - see `reset()` for reasons.
117+
self._deps = self._bom._dependencies
118+
119+
def __enter__(self) -> None:
120+
self.flatten_merge()
121+
122+
def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
123+
self.reset()
124+
125+
def flatten_merge(self) -> None:
126+
"""
127+
Flatten and merge all :attr:`cyclonedx.model.bom.Bom.dependencies`.
128+
129+
This produces a non‑recursive, merged representation of the entire
130+
dependency graph and assigns it to the Bom.
131+
132+
.. note::
133+
The original dependency graph is not modified. A new, flattened
134+
dependency structure is assigned to the Bom.
135+
"""
136+
self._bom.dependencies = self._flatten_merge(self._deps)
137+
138+
def reset(self) -> None:
139+
"""
140+
Restore the :class:`cyclonedx.model.bom.Bom`'s dependency graph to
141+
its original state.
142+
143+
.. note::
144+
This does not modify the dependency graph. It simply reassigns
145+
the original dependency collection back to the Bom.
146+
"""
147+
# NOTE: not using the setter, which would create overhead,
148+
# and - most importantly - this could cause deduplication of an existing malformed set.
149+
# Just access the internal field directly!
150+
self._bom._dependencies = self._deps
151+
152+
@staticmethod
153+
def _flatten_merge(deps: Iterable[Dependency]) -> Iterable[Dependency]:
154+
flat: dict[BomRef, list[BomRef]] = {}
155+
todos = list(deps)
156+
seen: list[int] = []
157+
while todos:
158+
todo = todos.pop()
159+
if (todo_id := id(todo)) in seen:
160+
continue
161+
seen.append(todo_id)
162+
ds = flat.setdefault(todo.ref, [])
163+
if todo_deps := todo.dependencies:
164+
ds.extend(d.ref for d in todo_deps)
165+
todos.extend(todo_deps)
166+
return (
167+
Dependency(br, (Dependency(d) for d in ds))
168+
for br, ds
169+
in flat.items()
170+
)

tests/test_contrib/test_bom_utils.py

Lines changed: 143 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,10 @@
1818

1919
from unittest import TestCase
2020

21-
from cyclonedx.contrib.bom.utils import BomRefDiscriminator
21+
from cyclonedx.contrib.bom.utils import BomDependencyGraphFlatMerger, BomRefDiscriminator
22+
from cyclonedx.model.bom import Bom
2223
from cyclonedx.model.bom_ref import BomRef
24+
from cyclonedx.model.dependency import Dependency
2325

2426

2527
class TestBomRefDiscriminator(TestCase):
@@ -46,3 +48,143 @@ def test_discriminate_and_reset_with(self) -> None:
4648
self.assertNotEqual(bomref1.value, bomref2.value, 'should be discriminated')
4749
self.assertEqual('djdlkfjdslkf', bomref1.value)
4850
self.assertEqual('djdlkfjdslkf', bomref2.value)
51+
52+
53+
class TestBomDependencyGraphFlatMerger(TestCase):
54+
55+
def test_flatten_merge_and_reset_manually(self) -> None:
56+
root_bom_ref = BomRef('root_bom_ref')
57+
component1_bom_ref = BomRef('component1_bom_ref')
58+
component2_bom_ref = BomRef('component2_bom_ref')
59+
component3_bom_ref = BomRef('component3_bom_ref')
60+
component4_bom_ref = BomRef('component4_bom_ref')
61+
component5_bom_ref = BomRef('component5_bom_ref')
62+
bom = Bom(dependencies=[
63+
Dependency(
64+
root_bom_ref,
65+
dependencies=[
66+
component1_bom_dep := Dependency(
67+
component1_bom_ref,
68+
dependencies=[
69+
component2_bom_dep := Dependency(
70+
component2_bom_ref,
71+
dependencies=[
72+
component3_bom_dep := Dependency(component3_bom_ref),
73+
]
74+
),
75+
]
76+
),
77+
component2_bom_dep2 := Dependency(
78+
component2_bom_ref,
79+
dependencies=[
80+
component4_bom_dep := Dependency(component4_bom_ref),
81+
]
82+
),
83+
]
84+
),
85+
component3_bom_dep2 := Dependency(
86+
component3_bom_ref,
87+
dependencies=[
88+
component4_bom_dep2 := Dependency(component4_bom_ref),
89+
]
90+
),
91+
Dependency(component5_bom_ref, (
92+
component1_bom_dep,
93+
component2_bom_dep,
94+
component2_bom_dep2,
95+
component3_bom_dep,
96+
component3_bom_dep2,
97+
component4_bom_dep,
98+
component4_bom_dep2,
99+
Dependency(root_bom_ref)
100+
))
101+
])
102+
bom_dependencies = bom.dependencies
103+
merger = BomDependencyGraphFlatMerger(bom)
104+
merger.flatten_merge()
105+
self.assertEqual(6, len(bom.dependencies), 'not expected len()')
106+
self.assertSetEqual({
107+
Dependency(root_bom_ref, (Dependency(component1_bom_ref), Dependency(component2_bom_ref))),
108+
Dependency(component1_bom_ref, (Dependency(component2_bom_ref), )),
109+
Dependency(component2_bom_ref, (Dependency(component3_bom_ref), Dependency(component4_bom_ref), )),
110+
Dependency(component3_bom_ref, (Dependency(component4_bom_ref), )),
111+
Dependency(component4_bom_ref),
112+
Dependency(component5_bom_ref, (
113+
Dependency(root_bom_ref),
114+
Dependency(component1_bom_ref),
115+
Dependency(component2_bom_ref),
116+
Dependency(component3_bom_ref),
117+
Dependency(component4_bom_ref),
118+
)),
119+
}, bom.dependencies)
120+
merger.reset()
121+
self.assertIs(bom_dependencies, bom.dependencies)
122+
self.assertSetEqual(bom_dependencies, bom.dependencies)
123+
124+
def test_flatten_merge_and_reset_with(self) -> None:
125+
root_bom_ref = BomRef('root_bom_ref')
126+
component1_bom_ref = BomRef('component1_bom_ref')
127+
component2_bom_ref = BomRef('component2_bom_ref')
128+
component3_bom_ref = BomRef('component3_bom_ref')
129+
component4_bom_ref = BomRef('component4_bom_ref')
130+
component5_bom_ref = BomRef('component5_bom_ref')
131+
bom = Bom(dependencies=[
132+
Dependency(
133+
root_bom_ref,
134+
dependencies=[
135+
component1_bom_dep := Dependency(
136+
component1_bom_ref,
137+
dependencies=[
138+
component2_bom_dep := Dependency(
139+
component2_bom_ref,
140+
dependencies=[
141+
component3_bom_dep := Dependency(component3_bom_ref),
142+
]
143+
),
144+
]
145+
),
146+
component2_bom_dep2 := Dependency(
147+
component2_bom_ref,
148+
dependencies=[
149+
component4_bom_dep := Dependency(component4_bom_ref),
150+
]
151+
),
152+
]
153+
),
154+
component3_bom_dep2 := Dependency(
155+
component3_bom_ref,
156+
dependencies=[
157+
component4_bom_dep2 := Dependency(component4_bom_ref),
158+
]
159+
),
160+
Dependency(component5_bom_ref, (
161+
Dependency(root_bom_ref),
162+
component1_bom_dep,
163+
component2_bom_dep,
164+
component2_bom_dep2,
165+
component3_bom_dep,
166+
component3_bom_dep2,
167+
component4_bom_dep,
168+
component4_bom_dep2,
169+
))
170+
])
171+
bom_dependencies = bom.dependencies
172+
merger = BomDependencyGraphFlatMerger(bom)
173+
with merger:
174+
self.assertEqual(6, len(bom.dependencies), 'not expected len()')
175+
self.assertSetEqual({
176+
Dependency(root_bom_ref, (Dependency(component1_bom_ref), Dependency(component2_bom_ref))),
177+
Dependency(component1_bom_ref, (Dependency(component2_bom_ref), )),
178+
Dependency(component2_bom_ref, (Dependency(component3_bom_ref), Dependency(component4_bom_ref), )),
179+
Dependency(component3_bom_ref, (Dependency(component4_bom_ref), )),
180+
Dependency(component4_bom_ref),
181+
Dependency(component5_bom_ref, (
182+
Dependency(root_bom_ref),
183+
Dependency(component1_bom_ref),
184+
Dependency(component2_bom_ref),
185+
Dependency(component3_bom_ref),
186+
Dependency(component4_bom_ref),
187+
)),
188+
}, bom.dependencies)
189+
self.assertIs(bom_dependencies, bom.dependencies)
190+
self.assertSetEqual(bom_dependencies, bom.dependencies)

0 commit comments

Comments
 (0)