From 6d01dc18724dcd896bb7d474529d34fe8f0fe34b Mon Sep 17 00:00:00 2001 From: Sergio Garcia <47090312+singiamtel@users.noreply.github.com> Date: Tue, 3 Jun 2025 15:08:55 +0200 Subject: [PATCH] Find cycles in recipes (#917) --- bits_helpers/utilities.py | 21 +++++++++++++++++++++ tests/test_utilities.py | 37 ++++++++++++++++++++++++++++++++++++- 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/bits_helpers/utilities.py b/bits_helpers/utilities.py index 1c929c41..e4e6f510 100644 --- a/bits_helpers/utilities.py +++ b/bits_helpers/utilities.py @@ -64,6 +64,27 @@ def topological_sort(specs): edges = [(pkg, dep) for pkg, dep in edges if dep != current_package] # ...but keep blocking those that still depend on other stuff! leaves.extend(new_leaves - {pkg for pkg, _ in edges}) + # If we have any edges left, we have a cycle + if edges: + # Find a cycle by following dependencies + cycle = [] + start = edges[0][0] # Start with any remaining package + current = start + max_iter = 10000 # Prevent infinite loops + while max_iter > 0: + max_iter -= 1 + cycle.append(current) + # Find what current depends on + for pkg, dep in edges: + if pkg == current: + current = dep + break + if current in cycle: # We found a cycle + cycle = cycle[cycle.index(current):] # Trim to just the cycle + dieOnError(True, "Dependency cycle detected: " + " -> ".join(cycle + [cycle[0]])) + if current == start: # We've gone full circle + raise RuntimeError("Internal error: cycle detection failed") + assert False, "Unreachable error: cycle detection failed" def resolve_store_path(architecture, spec_hash): diff --git a/tests/test_utilities.py b/tests/test_utilities.py index 1016f410..c8f2c321 100644 --- a/tests/test_utilities.py +++ b/tests/test_utilities.py @@ -5,12 +5,14 @@ # Assuming you are using the mock library to ... mock things from unittest.mock import patch -from bits_helpers.utilities import doDetectArch, filterByArchitectureDefaults, disabledByArchitectureDefaults +from bits_helpers.utilities import doDetectArch, filterByArchitectureDefaults, disabledByArchitectureDefaults, getPkgDirs from bits_helpers.utilities import Hasher from bits_helpers.utilities import asList from bits_helpers.utilities import prunePaths from bits_helpers.utilities import resolve_version from bits_helpers.utilities import topological_sort +from bits_helpers.utilities import resolveFilename, resolveDefaultsFilename +import bits_helpers import os import string @@ -272,6 +274,39 @@ def test_dont_drop_packages(self) -> None: self.assertEqual(frozenset(specs.keys()), frozenset(topological_sort(specs))) + def test_cycle(self) -> None: + """Test that dependency cycles are detected and reported.""" + specs = { + "A": {"package": "A", "requires": ["B"]}, + "B": {"package": "B", "requires": ["C"]}, + "C": {"package": "C", "requires": ["D"]}, + "D": {"package": "D", "requires": ["A"]} + } + with patch.object(alibuild_helpers.log, 'error') as mock_error: + with self.assertRaises(SystemExit) as cm: + list(topological_sort(specs)) + self.assertEqual(cm.exception.code, 1) + mock_error.assert_called_once_with("%s", "Dependency cycle detected: A -> B -> C -> D -> A") + + def test_empty_set(self) -> None: + """Test that an empty set of packages is handled correctly.""" + self.assertEqual([], list(topological_sort({}))) + + def test_single_package(self) -> None: + """Test that a single package with no dependencies is handled correctly.""" + self.assertEqual(["A"], list(topological_sort({ + "A": {"package": "A", "requires": []} + }))) + + def test_independent_packages(self) -> None: + """Test that packages with no dependencies between them are handled correctly.""" + result = list(topological_sort({ + "A": {"package": "A", "requires": []}, + "B": {"package": "B", "requires": []}, + "C": {"package": "C", "requires": []} + })) + self.assertEqual(set(["A", "B", "C"]), set(result)) + self.assertEqual(3, len(result)) if __name__ == '__main__': unittest.main()