Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AUTHORS
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,7 @@ Fraser Stark
Freya Bruhin
Gabriel Landau
Gabriel Reis
Gang Eun Lee
Garion Milazzo
Garvit Shubham
Gene Wood
Expand Down
3 changes: 3 additions & 0 deletions changelog/11783.bugfix.rst
Original file line number Diff line number Diff line change
@@ -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.
71 changes: 62 additions & 9 deletions src/_pytest/config/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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, ...]`
Expand Down
84 changes: 84 additions & 0 deletions testing/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,23 @@

from collections.abc import Sequence
import dataclasses
import importlib
import importlib.metadata
import os
from pathlib import Path
import re
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
Expand Down Expand Up @@ -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()
Expand Down