From b77840b2f783a5490ce887c61cc155d5cf2428df Mon Sep 17 00:00:00 2001 From: yuwk <1729065730@qq.com> Date: Thu, 27 Aug 2026 17:17:28 +0800 Subject: [PATCH 1/3] Implement get_code in AssertionRewritingHook for runpy runpy.run_module() calls the loader's get_code() method to obtain the module's code. AssertionRewritingHook did not implement get_code(), so a test module re-running itself via runpy crashed with: AttributeError: 'AssertionRewritingHook' object has no attribute 'get_code' Refactor the rewriting logic from exec_module() into a private _get_rewritten_code() helper and implement get_code() to return the rewritten code object for a module. Fixes #9007. --- AUTHORS | 1 + changelog/9007.bugfix.rst | 1 + src/_pytest/assertion/rewrite.py | 19 ++++++++++++++++++- testing/test_assertrewrite.py | 12 ++++++++++++ 4 files changed, 32 insertions(+), 1 deletion(-) create mode 100644 changelog/9007.bugfix.rst diff --git a/AUTHORS b/AUTHORS index d1a2d3e7911..f861fd71d27 100644 --- a/AUTHORS +++ b/AUTHORS @@ -533,6 +533,7 @@ Yuliang Shao Yusuke Kadowaki Yutian Li Yuval Shimon +yuwk Zac Hatfield-Dodds Zac Palmer Laporte Zach Snicker diff --git a/changelog/9007.bugfix.rst b/changelog/9007.bugfix.rst new file mode 100644 index 00000000000..886232e540e --- /dev/null +++ b/changelog/9007.bugfix.rst @@ -0,0 +1 @@ +The assertion-rewriting import hook now implements the ``get_code`` loader API, so that a test module can re-run itself using :func:`runpy.run_module` without crashing with ``AttributeError: 'AssertionRewritingHook' object has no attribute 'get_code'``. diff --git a/src/_pytest/assertion/rewrite.py b/src/_pytest/assertion/rewrite.py index 362c93d7253..84a89906479 100644 --- a/src/_pytest/assertion/rewrite.py +++ b/src/_pytest/assertion/rewrite.py @@ -152,6 +152,23 @@ def exec_module(self, module: types.ModuleType) -> None: self._rewritten_names[module.__name__] = fn + co = self._get_rewritten_code(fn, state) + exec(co, module.__dict__) + + def get_code(self, name: str) -> types.CodeType | None: + """Return the rewritten code object for *name*, if it can be found. + + This implements the optional ``get_code`` loader API, which is used by + :func:`runpy.run_module` among others, so that a test module can re-run + itself via ``runpy`` (see :issue:`9007`). + """ + state = self.config.stash[assertstate_key] + spec = self._find_spec(name) + if spec is None or spec.origin is None: + return None + return self._get_rewritten_code(Path(spec.origin), state) + + def _get_rewritten_code(self, fn: Path, state: AssertionState) -> types.CodeType: # The requested module looks like a test file, so rewrite it. This is # the most magical part of the process: load the source, rewrite the # asserts, and load the rewritten source. We also cache the rewritten @@ -184,7 +201,7 @@ def exec_module(self, module: types.ModuleType) -> None: self._writing_pyc = False else: state.trace(f"found cached rewritten pyc for {fn}") - exec(co, module.__dict__) + return co def _early_rewrite_bailout(self, name: str, state: AssertionState) -> bool: """A fast way to get out of rewriting modules. diff --git a/testing/test_assertrewrite.py b/testing/test_assertrewrite.py index 12e12449693..46e5bc41abc 100644 --- a/testing/test_assertrewrite.py +++ b/testing/test_assertrewrite.py @@ -1318,6 +1318,18 @@ def test_meta_path(): ) assert pytester.runpytest().ret == 0 + def test_runpy_run_module(self, pytester: Pytester) -> None: + """See #9007: re-running a test module with ``runpy`` should not crash.""" + pytester.makepyfile( + test_runpy=""" + import runpy + + def test_run_module(): + runpy.run_module("test_runpy") + """ + ) + pytester.runpytest().assert_outcomes(passed=1) + def test_write_pyc(self, pytester: Pytester, tmp_path) -> None: from _pytest.assertion import AssertionState from _pytest.assertion.rewrite import _write_pyc From 4dc90fe3c4b75528c7d407e682134d2582df3c56 Mon Sep 17 00:00:00 2001 From: yuwk Date: Thu, 27 Aug 2026 21:57:59 +0800 Subject: [PATCH 2/3] =?UTF-8?q?fix(assertion):=20=E5=AE=8C=E5=96=84=20runp?= =?UTF-8?q?y=20=E5=8C=85=E6=A8=A1=E5=9D=97=E9=87=8D=E5=86=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 优先复用已重写模块的真实文件路径,支持包内测试模块再次执行 - 覆盖嵌套 pytest 会话中外层重写钩子的生命周期 - 补齐未知模块返回路径并消除补丁覆盖率缺口 --- src/_pytest/assertion/rewrite.py | 11 ++++--- testing/test_assertrewrite.py | 49 ++++++++++++++++++++++++++++++-- 2 files changed, 53 insertions(+), 7 deletions(-) diff --git a/src/_pytest/assertion/rewrite.py b/src/_pytest/assertion/rewrite.py index 84a89906479..ff301506f5b 100644 --- a/src/_pytest/assertion/rewrite.py +++ b/src/_pytest/assertion/rewrite.py @@ -163,10 +163,13 @@ def get_code(self, name: str) -> types.CodeType | None: itself via ``runpy`` (see :issue:`9007`). """ state = self.config.stash[assertstate_key] - spec = self._find_spec(name) - if spec is None or spec.origin is None: - return None - return self._get_rewritten_code(Path(spec.origin), state) + fn = self._rewritten_names.get(name) + if fn is None: + spec = self._find_spec(name) + if spec is None or spec.origin is None: + return None + fn = Path(spec.origin) + return self._get_rewritten_code(fn, state) def _get_rewritten_code(self, fn: Path, state: AssertionState) -> types.CodeType: # The requested module looks like a test file, so rewrite it. This is diff --git a/testing/test_assertrewrite.py b/testing/test_assertrewrite.py index 46e5bc41abc..07cc8e223e0 100644 --- a/testing/test_assertrewrite.py +++ b/testing/test_assertrewrite.py @@ -1320,16 +1320,59 @@ def test_meta_path(): def test_runpy_run_module(self, pytester: Pytester) -> None: """See #9007: re-running a test module with ``runpy`` should not crash.""" - pytester.makepyfile( - test_runpy=""" + tests = pytester.mkpydir("tests") + tests.joinpath("test_runpy.py").write_text( + textwrap.dedent( + """ import runpy + import warnings def test_run_module(): - runpy.run_module("test_runpy") + with warnings.catch_warnings(): + warnings.simplefilter("ignore", RuntimeWarning) + runpy.run_module("tests.test_runpy") + """ + ), + encoding="utf-8", + ) + pytester.runpytest("tests/test_runpy.py").assert_outcomes(passed=1) + + def test_runpy_run_module_nested_session(self, pytester: Pytester) -> None: + """An outer rewrite hook remains usable while an inner session is active.""" + pytester.makepyfile( + test_outer=""" + import pytest + import textwrap + + def test_nested_session(tmp_path): + nested_test = tmp_path / "test_nested.py" + nested_test.write_text( + textwrap.dedent(''' + import runpy + import sys + + from _pytest.assertion.rewrite import AssertionRewritingHook + + def test_inner(): + outer_loader = sys.modules["test_outer"].__spec__.loader + assert isinstance(outer_loader, AssertionRewritingHook) + assert outer_loader in sys.meta_path + runpy.run_module("test_outer") + '''), + encoding="utf-8", + ) + assert pytest.main([str(nested_test), "-q"]) == pytest.ExitCode.OK """ ) pytester.runpytest().assert_outcomes(passed=1) + def test_get_code_unknown_module(self, pytestconfig, monkeypatch) -> None: + """The loader reports no code when the requested module cannot be found.""" + hook = AssertionRewritingHook(pytestconfig) + monkeypatch.setattr(hook, "_find_spec", lambda name: None) + + assert hook.get_code("unknown_module") is None + def test_write_pyc(self, pytester: Pytester, tmp_path) -> None: from _pytest.assertion import AssertionState from _pytest.assertion.rewrite import _write_pyc From 13f612d92f0afea815a84e883f87b2ed416d7936 Mon Sep 17 00:00:00 2001 From: yuwk Date: Thu, 27 Aug 2026 22:22:35 +0800 Subject: [PATCH 3/3] =?UTF-8?q?test(assertion):=20=E8=A6=86=E7=9B=96=20run?= =?UTF-8?q?py=20=E6=9C=AA=E5=8A=A0=E8=BD=BD=E6=A8=A1=E5=9D=97=E5=9B=9E?= =?UTF-8?q?=E9=80=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 构造可解析但尚未执行的测试模块\n- 覆盖 get_code 的有效 spec origin 分支\n- 补齐 Codecov 报告的缺失行与短路分支 --- testing/test_assertrewrite.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/testing/test_assertrewrite.py b/testing/test_assertrewrite.py index 07cc8e223e0..85d546df1b7 100644 --- a/testing/test_assertrewrite.py +++ b/testing/test_assertrewrite.py @@ -1373,6 +1373,18 @@ def test_get_code_unknown_module(self, pytestconfig, monkeypatch) -> None: assert hook.get_code("unknown_module") is None + def test_get_code_unloaded_module( + self, pytestconfig, monkeypatch, tmp_path: Path + ) -> None: + """The loader can rewrite a resolvable module it has not executed yet.""" + source = tmp_path / "test_unloaded.py" + source.write_text("assert True\n", encoding="utf-8") + hook = AssertionRewritingHook(pytestconfig) + spec = importlib.util.spec_from_file_location("test_unloaded", source) + monkeypatch.setattr(hook, "_find_spec", lambda name: spec) + + assert hook.get_code("test_unloaded") is not None + def test_write_pyc(self, pytester: Pytester, tmp_path) -> None: from _pytest.assertion import AssertionState from _pytest.assertion.rewrite import _write_pyc