diff --git a/AUTHORS b/AUTHORS index bd1e27cb37d..725315d0039 100644 --- a/AUTHORS +++ b/AUTHORS @@ -351,6 +351,7 @@ Milan Lesnek minbang930 Minh Dau Miro HronĨok +Muhammad Sharif Mulat Mekonen mrbean-bremen Nathan Goldbaum diff --git a/changelog/15157.bugfix.rst b/changelog/15157.bugfix.rst new file mode 100644 index 00000000000..394cd51d66e --- /dev/null +++ b/changelog/15157.bugfix.rst @@ -0,0 +1,3 @@ +Fix string-based :meth:`pytest.MonkeyPatch.setattr` and +:meth:`pytest.MonkeyPatch.delattr` for cached submodules whose attribute is missing +from the parent package. diff --git a/src/_pytest/monkeypatch.py b/src/_pytest/monkeypatch.py index 9b9bcd3fb95..663c3e2559a 100644 --- a/src/_pytest/monkeypatch.py +++ b/src/_pytest/monkeypatch.py @@ -79,14 +79,13 @@ def resolve(name: str) -> object: # We use explicit un-nesting of the handling block in order # to avoid nested exceptions. try: - importlib.import_module(used) + found = importlib.import_module(used) except ImportError as ex: expected = str(ex).split()[-1] if expected == used: raise else: raise ImportError(f"import error in {used}: {ex}") from ex - found = annotated_getattr(found, part, used) return found diff --git a/testing/test_monkeypatch.py b/testing/test_monkeypatch.py index 0d07783b05b..4ec5f9d09e6 100644 --- a/testing/test_monkeypatch.py +++ b/testing/test_monkeypatch.py @@ -2,6 +2,7 @@ from __future__ import annotations from collections.abc import Generator +import importlib import os from pathlib import Path import re @@ -53,6 +54,29 @@ class A: class TestSetattrWithImportPath: + @pytest.mark.parametrize("operation", ["setattr", "delattr"]) + def test_cached_module_without_parent_attribute( + self, pytester: Pytester, monkeypatch: MonkeyPatch, operation: str + ) -> None: + package_path = pytester.mkdir("cached_target_package") + (package_path / "__init__.py").write_text("", encoding="utf-8") + (package_path / "child.py").write_text("value = 1", encoding="utf-8") + monkeypatch.syspath_prepend(str(pytester.path)) + child = importlib.import_module("cached_target_package.child") + package = importlib.import_module("cached_target_package") + monkeypatch.delattr(package, "child") + + # A cached module is still importable without its parent attribute. + assert importlib.import_module("cached_target_package.child") is child + with monkeypatch.context() as mp: + if operation == "setattr": + mp.setattr("cached_target_package.child.value", 2) + assert child.value == 2 + else: + mp.delattr("cached_target_package.child.value") + assert not hasattr(child, "value") + assert child.value == 1 + def test_string_expression(self, monkeypatch: MonkeyPatch) -> None: with monkeypatch.context() as mp: mp.setattr("os.path.abspath", lambda x: "hello2")