From e664104ecf09e780dd4c4204c4899bfc30714a47 Mon Sep 17 00:00:00 2001 From: Zain Dana Harper Date: Mon, 29 Jun 2026 14:34:51 -0700 Subject: [PATCH] Raise ValueError when a module is assigned to more than one layer find_illegal_dependencies_for_layers panicked with an opaque pyo3_runtime.PanicException ("no entry found for key") when the same module appeared in more than one layer. Add a Python-side guard that raises a descriptive ValueError naming the repeated module(s) before the call reaches the Rust extension, and cover it with tests. Fixes #165. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.rst | 3 ++ src/grimp/application/graph.py | 23 ++++++++++++++ tests/unit/application/graph/test_layers.py | 33 +++++++++++++++++++++ 3 files changed, 59 insertions(+) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 12dc6070..2f04ea5b 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -10,6 +10,9 @@ latest * Tweak Just commands for running version-specific Python tests. * Remove `typing-extensions` as a dependency. * Make package FIPS compatible by marking blake2 hashing with `usedforsecurity=False`. +* Raise a descriptive `ValueError` instead of an unhelpful panic when the same module is assigned to + more than one layer in `find_illegal_dependencies_for_layers`. + (https://github.com/python-grimp/grimp/issues/165) 3.14 (2025-12-10) ----------------- diff --git a/src/grimp/application/graph.py b/src/grimp/application/graph.py index 21533f18..4a6e95a5 100644 --- a/src/grimp/application/graph.py +++ b/src/grimp/application/graph.py @@ -433,9 +433,11 @@ def find_illegal_dependencies_for_layers( lower layer (the 'upstream') to the higher layer (the 'downstream'). Raises: + ValueError: if the same module is assigned to more than one layer. NoSuchContainer: if the container is not a module in the graph. """ layers = _parse_layers(layers) + _check_layers_do_not_share_modules(layers) try: result = self._rustgraph.find_illegal_dependencies_for_layers( layers=tuple( @@ -518,6 +520,27 @@ def _parse_layers(layers: Sequence[Layer | str | set[str]]) -> tuple[Layer, ...] return tuple(out_layers) +def _check_layers_do_not_share_modules(layers: tuple[Layer, ...]) -> None: + """ + Raise ValueError if any module is assigned to more than one layer. + + A module cannot sit at two different levels at once. Without this guard a + repeated module surfaces as an unhelpful panic from the underlying Rust + extension rather than a clear error. + """ + seen: set[str] = set() + duplicates: set[str] = set() + for layer in layers: + for module in layer.module_tails: + if module in seen: + duplicates.add(module) + seen.add(module) + if duplicates: + formatted = ", ".join(repr(module) for module in sorted(duplicates)) + msg = f"Modules can only belong to one layer, but the following appear in more than one: {formatted}." + raise ValueError(msg) + + def _dependencies_from_tuple( rust_package_dependency_tuple: tuple[_RustPackageDependency, ...], ) -> set[PackageDependency]: diff --git a/tests/unit/application/graph/test_layers.py b/tests/unit/application/graph/test_layers.py index 91da4f75..0a04dc7b 100644 --- a/tests/unit/application/graph/test_layers.py +++ b/tests/unit/application/graph/test_layers.py @@ -840,6 +840,39 @@ def test_single_missing_container(self, missing_container: str): ) +class TestInvalidLayers: + def test_module_in_multiple_layers_raises_value_error(self): + graph = ImportGraph() + graph.add_module("mypackage") + graph.add_module("mypackage.foo") + graph.add_module("mypackage.bar") + + with pytest.raises( + ValueError, + match=re.escape( + "Modules can only belong to one layer, but the following appear in " + "more than one: 'mypackage.foo'." + ), + ): + graph.find_illegal_dependencies_for_layers( + layers=("mypackage.foo", "mypackage.bar", "mypackage.foo"), + ) + + def test_module_shared_across_sibling_layers_raises_value_error(self): + graph = ImportGraph() + + with pytest.raises( + ValueError, + match=re.escape( + "Modules can only belong to one layer, but the following appear in " + "more than one: 'shared'." + ), + ): + graph.find_illegal_dependencies_for_layers( + layers=(Layer("high", "shared"), Layer("shared", "low")), + ) + + # TODO: move test to within Rust. @pytest.mark.skip(reason="This only passes if run on its own, due to pyo3_log caching.") class TestLogging: