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
3 changes: 3 additions & 0 deletions changelog/14683.bugfix.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
Fixed a regression in pytest 9.1 where a conftest.py located in the rootdir was no longer visible to tests and doctests collected from *outside* the rootdir (for example when passing a parent directory of the rootdir as a collection argument, or setting ``--rootdir`` to a subdirectory).

As a result, fixtures defined in such a conftest -- including ``doctest_namespace`` injections -- were not available, causing errors such as ``NameError`` in doctests.
36 changes: 31 additions & 5 deletions src/_pytest/fixtures.py
Original file line number Diff line number Diff line change
Expand Up @@ -1799,21 +1799,47 @@ def pytest_make_collect_report(
if isinstance(collector, nodes.Directory):
plugin = self._pending_conftests.pop(collector.path, None)
if plugin is not None:
self.parsefactories(holder=plugin, node=collector)
# A conftest located in the rootdir historically got an empty
# baseid (matching the whole collection tree), so its fixtures
# stayed visible even to items collected from outside the
# rootdir. Keep that behavior by attaching it to the Session
# instead of this Directory node (#14683).
scope_node = (
self.session
if collector.path == self.config.rootpath
else collector
)
self.parsefactories(holder=plugin, node=scope_node)
return result

def _flush_pending_conftests_to_session(self, session: Session) -> None:
"""Assign Session scope to initial conftests whose directories won't
be collected as Directory nodes (e.g. ancestors above rootdir)."""
"""Assign Session scope to initial conftests whose fixtures should be
visible to the entire collection tree.

This covers the conftests that, with the nodeid-based scoping used
before pytest 9.1, ended up with an empty baseid (which matches every
collected item) and were therefore visible session-wide:

* Conftests in directories above the rootdir. These never get their own
Directory collector, so they cannot be scoped to one.
* The conftest located directly in the rootdir. It used to get an empty
baseid, so its fixtures were available even to items collected from
*outside* the rootdir -- e.g. when a parent of the rootdir is passed
as a collection argument. Attach it to the Session to preserve that
behavior (regression in #14683).
"""
rootpath = session.config.rootpath
orphaned: list[tuple[Path, object]] = []
for conftest_dir, plugin in list(self._pending_conftests.items()):
# If the conftest dir is not under rootpath, it will never get
# a Directory collector — assign it to Session now.
# Conftests outside of the rootdir never get a Directory collector.
try:
conftest_dir.relative_to(rootpath)
except ValueError:
orphaned.append((conftest_dir, plugin))
continue
# The rootdir conftest used to have an empty baseid (matches all).
if conftest_dir == rootpath:
orphaned.append((conftest_dir, plugin))
for conftest_dir, plugin in orphaned:
del self._pending_conftests[conftest_dir]
self.parsefactories(holder=plugin, node=session)
Expand Down
48 changes: 48 additions & 0 deletions testing/test_conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -911,6 +911,54 @@ def test_uses_ancestor(ancestor_fixture):
result.stdout.fnmatch_lines(["*test_uses_ancestor*PASSED*", "*1 passed*"])


def test_rootdir_conftest_visible_outside_rootdir(pytester: Pytester) -> None:
"""A conftest located in the rootdir provides fixtures to items that are
collected from *outside* the rootdir.

Before pytest 9.1 the rootdir conftest got an empty baseid (which matches
every collected item), so its fixtures were visible session-wide. The
node-based scoping introduced in #14098 (pytest 9.1) inadvertently scoped
it to its own Directory node, making it invisible to items collected from
a parent/sibling of the rootdir. Regression test for #14683.

Layout::

project/ <- pytest invoked here
xclim/ <- collected (``--doctest-modules xclim``)
testing/ <- rootdir (``--rootdir xclim/testing``)
conftest.py <- defines a fixture
core/
test_it.py <- collected from outside rootdir
"""
root = pytester.path
testing = root / "xclim" / "testing"
testing.mkdir(parents=True)
testing.joinpath("conftest.py").write_text(
textwrap.dedent("""\
import pytest

@pytest.fixture
def rootdir_fixture():
return "from-rootdir"
"""),
encoding="utf-8",
)
core = root / "xclim" / "core"
core.mkdir()
core.joinpath("test_it.py").write_text(
textwrap.dedent("""\
def test_uses_rootdir(rootdir_fixture):
assert rootdir_fixture == "from-rootdir"
"""),
encoding="utf-8",
)

result = pytester.runpytest(
"--rootdir", str(testing), "--doctest-modules", "xclim", "-v"
)
result.stdout.fnmatch_lines(["*test_uses_rootdir*PASSED*", "*1 passed*"])


def test_required_option_help(pytester: Pytester) -> None:
pytester.makeconftest("assert 0")
x = pytester.mkdir("x")
Expand Down
53 changes: 53 additions & 0 deletions testing/test_doctest.py
Original file line number Diff line number Diff line change
Expand Up @@ -1550,6 +1550,59 @@ def foo():
reprec = pytester.inline_run(p, "--doctest-modules")
reprec.assertoutcome(passed=1)

def test_namespace_fixture_from_rootdir_when_modules_outside_rootdir(
self, pytester: Pytester
) -> None:
"""doctest_namespace injection from a conftest in the rootdir still
applies when the doctest modules are collected from outside the
rootdir.

Regression test for #14683: setting ``--rootdir`` to a subdirectory
while collecting modules from a parent directory made the rootdir
conftest's ``doctest_namespace`` injection invisible.
"""
testing = pytester.path / "xclim" / "testing"
testing.mkdir(parents=True)
testing.joinpath("conftest.py").write_text(
textwrap.dedent(
"""\
import pytest

@pytest.fixture(autouse=True, scope="session")
def add_var(doctest_namespace):
doctest_namespace["my_var"] = 42
"""
),
encoding="utf-8",
)
core = pytester.path / "xclim" / "core"
core.mkdir()
core.joinpath("mod.py").write_text(
textwrap.dedent(
"""\
def func():
'''
>>> my_var
42
'''
"""
),
encoding="utf-8",
)

# The conftest is both the config file and at the rootdir, and the
# collection argument (``xclim``) is a *parent* of the rootdir
# (``xclim/testing``) -- the exact setup from #14683.
result = pytester.runpytest(
"--rootdir",
str(testing),
"--config-file",
str(testing / "conftest.py"),
"--doctest-modules",
"xclim",
)
result.assert_outcomes(passed=1)


class TestDoctestReportingOption:
def _run_doctest_report(self, pytester, format):
Expand Down
Loading