From 04de0179dbefe1de9daddfe650cdfefe804ed377 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EA=B0=95=EC=9D=80?= <73535356+GangEunzzang@users.noreply.github.com> Date: Wed, 16 Sep 2026 19:58:58 +0900 Subject: [PATCH 1/4] Mark editable-installed plugins for assertion rewriting A PEP 660 editable install does not record the package's Python files in the distribution metadata, so _mark_plugins_for_rewrite() found no module to mark and assertions inside the plugin were no longer rewritten. Fall back to the top-level package of the pytest11 entry points when a distribution is installed in editable mode and its recorded files yield nothing. Editable mode is read from direct_url.json. Fixes #11783 --- AUTHORS | 1 + changelog/11783.bugfix.rst | 3 +++ src/_pytest/config/__init__.py | 49 +++++++++++++++++++++++++++------- testing/test_config.py | 45 +++++++++++++++++++++++++++++++ 4 files changed, 89 insertions(+), 9 deletions(-) create mode 100644 changelog/11783.bugfix.rst diff --git a/AUTHORS b/AUTHORS index ad4c2093892..288c4cd3fd4 100644 --- a/AUTHORS +++ b/AUTHORS @@ -189,6 +189,7 @@ Fraser Stark Freya Bruhin Gabriel Landau Gabriel Reis +Gang Eun Lee Garion Milazzo Garvit Shubham Gene Wood diff --git a/changelog/11783.bugfix.rst b/changelog/11783.bugfix.rst new file mode 100644 index 00000000000..f53b39f5116 --- /dev/null +++ b/changelog/11783.bugfix.rst @@ -0,0 +1,3 @@ +Plugins installed in editable mode (:pep:`660`) are now marked for assertion rewriting again. + +Such installs do not record the package's Python files in the distribution metadata, so pytest found no modules to rewrite and assertions inside the plugin lost their introspection. The plugin's entry point is now used as a fallback in that case. diff --git a/src/_pytest/config/__init__.py b/src/_pytest/config/__init__.py index c7bd3e1afab..e0d94d045d5 100644 --- a/src/_pytest/config/__init__.py +++ b/src/_pytest/config/__init__.py @@ -22,6 +22,7 @@ import importlib import importlib.metadata import inspect +import json import os import pathlib import re @@ -1006,6 +1007,28 @@ def _get_plugin_specs_as_list( ) +def _is_editable_install(dist: importlib.metadata.Distribution) -> bool: + """Whether the distribution was installed in editable mode (PEP 660). + + Such installs do not record the package's Python files in the distribution + metadata, so they need special handling when looking for modules to rewrite. + """ + read_text = getattr(dist, "read_text", None) + if read_text is None: + return False + try: + direct_url = read_text("direct_url.json") + except OSError: + return False + if not direct_url: + return False + try: + dir_info = json.loads(direct_url).get("dir_info", {}) + except ValueError: + return False + return bool(dir_info.get("editable")) + + def _iter_rewritable_modules(package_files: Iterable[str]) -> Iterator[str]: """Given an iterable of file names in a source distribution, return the "names" that should be marked for assertion rewrite. @@ -1477,15 +1500,23 @@ def _mark_plugins_for_rewrite( # no need to continue. return - package_files = ( - str(file) - for dist in importlib.metadata.distributions() - if any(ep.group == "pytest11" for ep in dist.entry_points) - for file in dist.files or [] - ) - - for name in _iter_rewritable_modules(package_files): - hook.mark_rewrite(name) + for dist in importlib.metadata.distributions(): + entry_points = [ep for ep in dist.entry_points if ep.group == "pytest11"] + if not entry_points: + continue + names = set( + _iter_rewritable_modules(str(file) for file in dist.files or []) + ) + if not names and _is_editable_install(dist): + # An editable install lists no Python files, so fall back to the + # top-level package of each plugin entry point (#11783). + names = { + top_level + for ep in entry_points + if (top_level := ep.value.partition(":")[0].strip().split(".")[0]) + } + for name in names: + hook.mark_rewrite(name) def _configure_python_path(self) -> None: # `pythonpath = a b` will set `sys.path` to `[a, b, x, y, z, ...]` diff --git a/testing/test_config.py b/testing/test_config.py index dad1653e299..ca7158463e5 100644 --- a/testing/test_config.py +++ b/testing/test_config.py @@ -1669,6 +1669,51 @@ def test_confcutdir_check_isdir(self, pytester: Pytester) -> None: def test_iter_rewritable_modules(self, names, expected) -> None: assert list(_iter_rewritable_modules(names)) == expected + @pytest.mark.parametrize( + "direct_url, expected", + [ + # PEP 660 editable install: no Python files are recorded, so the + # entry point is the only way to find the package (#11783). + ('{"dir_info": {"editable": true}, "url": "file:///src"}', ["myplug"]), + # A regular install of a local directory is not editable. + ('{"dir_info": {}, "url": "file:///src"}', []), + # Installed from an index: no direct_url.json at all. + (None, []), + ], + ) + def test_mark_plugins_for_rewrite_editable_install( + self, pytester: Pytester, monkeypatch: MonkeyPatch, direct_url, expected + ) -> None: + """Plugins installed in editable mode are still marked for rewrite.""" + + class DummyEntryPoint: + name = "myplug" + group = "pytest11" + value = "myplug.plugin" + + class DummyDistribution: + metadata = {"name": "myplug"} + entry_points = (DummyEntryPoint(),) + files = () + + def read_text(self, filename): + return direct_url if filename == "direct_url.json" else None + + class DummyHook: + def __init__(self): + self.marked: list[str] = [] + + def mark_rewrite(self, *names: str) -> None: + self.marked.extend(names) + + monkeypatch.setattr( + importlib.metadata, "distributions", lambda: (DummyDistribution(),) + ) + hook = DummyHook() + config = pytester.parseconfig() + config._mark_plugins_for_rewrite(hook, disable_autoload=False) + assert hook.marked == expected + def test_add_cleanup(self, pytester: Pytester) -> None: config = Config.fromdictargs({}, []) config._do_configure() From c7297cb3f7b3ece3931451a11562bd82971a53a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EA=B0=95=EC=9D=80?= <73535356+GangEunzzang@users.noreply.github.com> Date: Wed, 16 Sep 2026 20:01:54 +0900 Subject: [PATCH 2/4] Type the test hook to satisfy mypy Use typing.cast for the recording hook passed to _mark_plugins_for_rewrite(), matching the pattern used elsewhere in the test suite. --- testing/test_config.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/testing/test_config.py b/testing/test_config.py index ca7158463e5..bc51a13dc7e 100644 --- a/testing/test_config.py +++ b/testing/test_config.py @@ -10,9 +10,11 @@ import sys import textwrap from typing import Any +from typing import cast from typing import Literal import _pytest._code +from _pytest.assertion.rewrite import AssertionRewritingHook from _pytest.config import _get_plugin_specs_as_list from _pytest.config import _get_prog_name from _pytest.config import _iter_rewritable_modules @@ -1711,7 +1713,9 @@ def mark_rewrite(self, *names: str) -> None: ) hook = DummyHook() config = pytester.parseconfig() - config._mark_plugins_for_rewrite(hook, disable_autoload=False) + config._mark_plugins_for_rewrite( + cast(AssertionRewritingHook, hook), disable_autoload=False + ) assert hook.marked == expected def test_add_cleanup(self, pytester: Pytester) -> None: From b55862ac5665e3570364143a2ba67a92fabd0ab3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EA=B0=95=EC=9D=80?= <73535356+GangEunzzang@users.noreply.github.com> Date: Wed, 16 Sep 2026 21:00:38 +0900 Subject: [PATCH 3/4] Skip namespace packages when resolving the plugin package Marking the top-level name of an entry point rewrites every distribution sharing that name when it is a namespace package. Walk the entry point module instead and take the outermost package that is not a namespace package. PathFinder is used rather than importlib.util.find_spec because the latter imports the parent package, which has to stay unimported until it is marked. --- changelog/11783.bugfix.rst | 4 ++-- src/_pytest/config/__init__.py | 36 ++++++++++++++++++++++++++++++---- testing/test_config.py | 33 ++++++++++++++++++++++++++++++- 3 files changed, 66 insertions(+), 7 deletions(-) diff --git a/changelog/11783.bugfix.rst b/changelog/11783.bugfix.rst index f53b39f5116..3ff6aa504bc 100644 --- a/changelog/11783.bugfix.rst +++ b/changelog/11783.bugfix.rst @@ -1,3 +1,3 @@ -Plugins installed in editable mode (:pep:`660`) are now marked for assertion rewriting again. +Plugins installed in editable mode (:pep:`660`) are marked for assertion rewriting again. -Such installs do not record the package's Python files in the distribution metadata, so pytest found no modules to rewrite and assertions inside the plugin lost their introspection. The plugin's entry point is now used as a fallback in that case. +Such installs do not record the package's Python files in the distribution metadata, so pytest found nothing to rewrite and assertions inside the plugin lost their introspection. The plugin's entry point is now used to locate the package in that case. diff --git a/src/_pytest/config/__init__.py b/src/_pytest/config/__init__.py index e0d94d045d5..6fabe0c9dae 100644 --- a/src/_pytest/config/__init__.py +++ b/src/_pytest/config/__init__.py @@ -20,6 +20,7 @@ from functools import lru_cache import glob import importlib +import importlib.machinery import importlib.metadata import inspect import json @@ -1007,6 +1008,31 @@ def _get_plugin_specs_as_list( ) +def _plugin_rewrite_name(module: str) -> str | None: + """Return the name to mark so that ``module`` is rewritten. + + This is the outermost package of ``module`` that is not a namespace + package: a namespace package can be shared with distributions that have + nothing to do with the plugin, and marking it would rewrite those too. + + Names are resolved through ``PathFinder`` because it does not import + anything, and the plugin must not be imported before it is marked. + """ + parts = module.split(".") + search_path: list[str] | None = None + for i, part in enumerate(parts): + try: + spec = importlib.machinery.PathFinder.find_spec(part, search_path) + except (ImportError, AttributeError, ValueError): + return None + if spec is None: + return None + if spec.origin is not None or spec.submodule_search_locations is None: + return ".".join(parts[: i + 1]) + search_path = list(spec.submodule_search_locations) + return None + + def _is_editable_install(dist: importlib.metadata.Distribution) -> bool: """Whether the distribution was installed in editable mode (PEP 660). @@ -1508,12 +1534,14 @@ def _mark_plugins_for_rewrite( _iter_rewritable_modules(str(file) for file in dist.files or []) ) if not names and _is_editable_install(dist): - # An editable install lists no Python files, so fall back to the - # top-level package of each plugin entry point (#11783). + # An editable install lists no Python files, so fall back + # to the package owning each plugin entry point (#11783). names = { - top_level + name for ep in entry_points - if (top_level := ep.value.partition(":")[0].strip().split(".")[0]) + if ( + name := _plugin_rewrite_name(ep.value.partition(":")[0].strip()) + ) } for name in names: hook.mark_rewrite(name) diff --git a/testing/test_config.py b/testing/test_config.py index bc51a13dc7e..1c20265a55c 100644 --- a/testing/test_config.py +++ b/testing/test_config.py @@ -3,6 +3,7 @@ from collections.abc import Sequence import dataclasses +import importlib import importlib.metadata import os from pathlib import Path @@ -18,6 +19,7 @@ from _pytest.config import _get_plugin_specs_as_list from _pytest.config import _get_prog_name from _pytest.config import _iter_rewritable_modules +from _pytest.config import _plugin_rewrite_name from _pytest.config import _strtobool from _pytest.config import Config from _pytest.config import ConftestImportFailure @@ -1671,6 +1673,26 @@ def test_confcutdir_check_isdir(self, pytester: Pytester) -> None: def test_iter_rewritable_modules(self, names, expected) -> None: assert list(_iter_rewritable_modules(names)) == expected + def test_plugin_rewrite_name_skips_namespace_packages( + self, tmp_path: Path, monkeypatch: MonkeyPatch + ) -> None: + """A namespace package can be shared with unrelated distributions, so + the plugin's own package is marked instead of the whole namespace.""" + (tmp_path / "myns" / "plug").mkdir(parents=True) + (tmp_path / "myns" / "plug" / "__init__.py").touch() + (tmp_path / "myns" / "other").mkdir(parents=True) + (tmp_path / "myns" / "other" / "__init__.py").touch() + (tmp_path / "myplug").mkdir() + (tmp_path / "myplug" / "__init__.py").touch() + monkeypatch.syspath_prepend(tmp_path) + importlib.invalidate_caches() + + # "myns" is a namespace package, so descend into the plugin's package. + assert _plugin_rewrite_name("myns.plug.plugin") == "myns.plug" + # A regular package is marked as a whole, so its helpers are rewritten. + assert _plugin_rewrite_name("myplug.plugin") == "myplug" + assert _plugin_rewrite_name("does_not_exist.plugin") is None + @pytest.mark.parametrize( "direct_url, expected", [ @@ -1684,9 +1706,18 @@ def test_iter_rewritable_modules(self, names, expected) -> None: ], ) def test_mark_plugins_for_rewrite_editable_install( - self, pytester: Pytester, monkeypatch: MonkeyPatch, direct_url, expected + self, + pytester: Pytester, + tmp_path: Path, + monkeypatch: MonkeyPatch, + direct_url, + expected, ) -> None: """Plugins installed in editable mode are still marked for rewrite.""" + (tmp_path / "myplug").mkdir() + (tmp_path / "myplug" / "__init__.py").touch() + monkeypatch.syspath_prepend(tmp_path) + importlib.invalidate_caches() class DummyEntryPoint: name = "myplug" From f38448e6e32ab80bb714e66b7a63a2df76f3a0a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EA=B0=95=EC=9D=80?= <73535356+GangEunzzang@users.noreply.github.com> Date: Wed, 16 Sep 2026 21:08:40 +0900 Subject: [PATCH 4/4] Drop unreachable guards and cover the remaining branches PathFinder.find_spec returns None for names it cannot resolve rather than raising, and Distribution.read_text already suppresses the errors raised by a missing metadata file, so neither guard could be reached. --- src/_pytest/config/__init__.py | 10 ++-------- testing/test_config.py | 4 ++++ 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/src/_pytest/config/__init__.py b/src/_pytest/config/__init__.py index 6fabe0c9dae..39db7538cc7 100644 --- a/src/_pytest/config/__init__.py +++ b/src/_pytest/config/__init__.py @@ -1021,10 +1021,7 @@ def _plugin_rewrite_name(module: str) -> str | None: parts = module.split(".") search_path: list[str] | None = None for i, part in enumerate(parts): - try: - spec = importlib.machinery.PathFinder.find_spec(part, search_path) - except (ImportError, AttributeError, ValueError): - return None + spec = importlib.machinery.PathFinder.find_spec(part, search_path) if spec is None: return None if spec.origin is not None or spec.submodule_search_locations is None: @@ -1042,10 +1039,7 @@ def _is_editable_install(dist: importlib.metadata.Distribution) -> bool: read_text = getattr(dist, "read_text", None) if read_text is None: return False - try: - direct_url = read_text("direct_url.json") - except OSError: - return False + direct_url = read_text("direct_url.json") if not direct_url: return False try: diff --git a/testing/test_config.py b/testing/test_config.py index 1c20265a55c..1a37d0c8a1c 100644 --- a/testing/test_config.py +++ b/testing/test_config.py @@ -1692,6 +1692,8 @@ def test_plugin_rewrite_name_skips_namespace_packages( # A regular package is marked as a whole, so its helpers are rewritten. assert _plugin_rewrite_name("myplug.plugin") == "myplug" assert _plugin_rewrite_name("does_not_exist.plugin") is None + # Nothing to mark when the entry point points at the namespace itself. + assert _plugin_rewrite_name("myns") is None @pytest.mark.parametrize( "direct_url, expected", @@ -1703,6 +1705,8 @@ def test_plugin_rewrite_name_skips_namespace_packages( ('{"dir_info": {}, "url": "file:///src"}', []), # Installed from an index: no direct_url.json at all. (None, []), + # Unreadable metadata is treated as a non-editable install. + ("not json", []), ], ) def test_mark_plugins_for_rewrite_editable_install(