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..3ff6aa504bc --- /dev/null +++ b/changelog/11783.bugfix.rst @@ -0,0 +1,3 @@ +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 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 c7bd3e1afab..39db7538cc7 100644 --- a/src/_pytest/config/__init__.py +++ b/src/_pytest/config/__init__.py @@ -20,8 +20,10 @@ from functools import lru_cache import glob import importlib +import importlib.machinery import importlib.metadata import inspect +import json import os import pathlib import re @@ -1006,6 +1008,47 @@ 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): + 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: + 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). + + 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 + direct_url = read_text("direct_url.json") + 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 +1520,25 @@ 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 package owning each plugin entry point (#11783). + names = { + name + for ep in entry_points + if ( + name := _plugin_rewrite_name(ep.value.partition(":")[0].strip()) + ) + } + 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..1a37d0c8a1c 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 @@ -10,12 +11,15 @@ 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 +from _pytest.config import _plugin_rewrite_name from _pytest.config import _strtobool from _pytest.config import Config from _pytest.config import ConftestImportFailure @@ -1669,6 +1673,86 @@ 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 + # 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", + [ + # 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, []), + # Unreadable metadata is treated as a non-editable install. + ("not json", []), + ], + ) + def test_mark_plugins_for_rewrite_editable_install( + 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" + 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( + cast(AssertionRewritingHook, hook), disable_autoload=False + ) + assert hook.marked == expected + def test_add_cleanup(self, pytester: Pytester) -> None: config = Config.fromdictargs({}, []) config._do_configure()