Skip modules with unavailable dependencies instead of aborting the run

A module declaring a dependency its command does not provide made
_ordered_modules() give up on the whole run, so a single wrong declaration
in a module scoped to several commands turned a forensic analysis into
zero executed modules with one warning to explain it.

Drop only the modules that cannot run: the one with the unavailable
dependency, and anything depending on it. Each gets its own warning naming
the module missing a dependency and the dependency it is missing, and the
remaining modules run in the same stable topological order as before. A
cycle in the dependency graph is still a programming error and still stops
the run.
This commit is contained in:
Donncha Ó Cearbhaill
2026-08-26 12:09:49 +02:00
parent ecc22f54b7
commit efbab29f94
3 changed files with 192 additions and 23 deletions
+87 -4
View File
@@ -157,7 +157,7 @@ class TestCommand:
assert not hasattr(cmd, "initialized")
assert "Circular module dependency detected" in caplog.text
def test_unavailable_dependency_warns_and_stops(self, caplog):
def test_unavailable_dependency_only_skips_the_dependent_module(self, caplog):
class UnavailableModule(RecordingModule):
pass
@@ -165,14 +165,97 @@ class TestCommand:
dependencies = (UnavailableModule,)
cmd = RecordingCommand()
cmd.modules = [DependentModule]
cmd.modules = [DependentModule, IndependentModule, FirstModule]
with caplog.at_level(logging.WARNING):
cmd.run()
assert RecordingModule.run_order == ["IndependentModule", "FirstModule"]
assert cmd.initialized
assert "Module DependentModule will be SKIPPED" in caplog.text
assert "depends on module UnavailableModule" in caplog.text
def test_modules_depending_on_a_skipped_module_are_skipped_too(self, caplog):
class UnavailableModule(RecordingModule):
pass
class SkippedModule(RecordingModule):
dependencies = (UnavailableModule,)
class DependsOnSkippedModule(RecordingModule):
dependencies = (SkippedModule,)
class DependsOnTheChain(RecordingModule):
dependencies = (DependsOnSkippedModule,)
cmd = RecordingCommand()
cmd.modules = [
DependsOnTheChain,
DependsOnSkippedModule,
SkippedModule,
IndependentModule,
]
with caplog.at_level(logging.WARNING):
cmd.run()
assert RecordingModule.run_order == ["IndependentModule"]
skip_warnings = [
record.getMessage()
for record in caplog.records
if "will be SKIPPED" in record.getMessage()
]
assert len(skip_warnings) == 3
assert [warning.split()[1] for warning in skip_warnings] == [
"SkippedModule",
"DependsOnSkippedModule",
"DependsOnTheChain",
]
# Every warning names the root cause: the module missing a dependency
# and the dependency it is missing.
assert all("UnavailableModule" in warning for warning in skip_warnings)
assert all("module SkippedModule" in warning for warning in skip_warnings[1:])
def test_explicitly_selected_module_with_missing_dependency_runs_nothing(
self, caplog
):
class UnavailableModule(RecordingModule):
pass
class DependentModule(RecordingModule):
dependencies = (UnavailableModule,)
cmd = RecordingCommand(module_name="DependentModule")
cmd.modules = [DependentModule, IndependentModule]
with caplog.at_level(logging.WARNING):
cmd.run()
assert RecordingModule.run_order == []
assert not hasattr(cmd, "initialized")
assert "depends on unavailable module UnavailableModule" in caplog.text
assert "Module DependentModule will be SKIPPED" in caplog.text
assert "No modules will be run" in caplog.text
# Nothing else was selected, so the warning must not promise that the
# analysis continues right before saying that it does not.
assert "The rest of the analysis will still run" not in caplog.text
def test_unaffected_dependency_chains_keep_their_order(self, caplog):
class UnavailableModule(RecordingModule):
pass
class SkippedModule(RecordingModule):
dependencies = (UnavailableModule,)
cmd = RecordingCommand()
cmd.modules = [ThirdModule, SkippedModule, SecondModule, FirstModule]
with caplog.at_level(logging.WARNING):
cmd.run()
assert RecordingModule.run_order == [
"FirstModule",
"SecondModule",
"ThirdModule",
]
def test_custom_modules_are_filtered_before_ordering(self):
cmd = RecordingCommand()