Skip to content

Commit ef052d0

Browse files
Merge pull request #14694 from breidenbach0/fix/doctest-namespace-not-loaded-outside-rootdir
Fix rootdir conftest fixtures not visible to items collected outside the rootdir
2 parents ced9022 + 3258779 commit ef052d0

4 files changed

Lines changed: 135 additions & 5 deletions

File tree

changelog/14683.bugfix.rst

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
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).
2+
3+
As a result, fixtures defined in such a conftest -- including ``doctest_namespace`` injections -- were not available, causing errors such as ``NameError`` in doctests.

src/_pytest/fixtures.py

Lines changed: 31 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1811,21 +1811,47 @@ def pytest_make_collect_report(
18111811
if isinstance(collector, nodes.Directory):
18121812
plugin = self._pending_conftests.pop(collector.path, None)
18131813
if plugin is not None:
1814-
self.parsefactories(holder=plugin, node=collector)
1814+
# A conftest located in the rootdir historically got an empty
1815+
# baseid (matching the whole collection tree), so its fixtures
1816+
# stayed visible even to items collected from outside the
1817+
# rootdir. Keep that behavior by attaching it to the Session
1818+
# instead of this Directory node (#14683).
1819+
scope_node = (
1820+
self.session
1821+
if collector.path == self.config.rootpath
1822+
else collector
1823+
)
1824+
self.parsefactories(holder=plugin, node=scope_node)
18151825
return result
18161826

18171827
def _flush_pending_conftests_to_session(self, session: Session) -> None:
1818-
"""Assign Session scope to initial conftests whose directories won't
1819-
be collected as Directory nodes (e.g. ancestors above rootdir)."""
1828+
"""Assign Session scope to initial conftests whose fixtures should be
1829+
visible to the entire collection tree.
1830+
1831+
This covers the conftests that, with the nodeid-based scoping used
1832+
before pytest 9.1, ended up with an empty baseid (which matches every
1833+
collected item) and were therefore visible session-wide:
1834+
1835+
* Conftests in directories above the rootdir. These never get their own
1836+
Directory collector, so they cannot be scoped to one.
1837+
* The conftest located directly in the rootdir. It used to get an empty
1838+
baseid, so its fixtures were available even to items collected from
1839+
*outside* the rootdir -- e.g. when a parent of the rootdir is passed
1840+
as a collection argument. Attach it to the Session to preserve that
1841+
behavior (regression in #14683).
1842+
"""
18201843
rootpath = session.config.rootpath
18211844
orphaned: list[tuple[Path, object]] = []
18221845
for conftest_dir, plugin in list(self._pending_conftests.items()):
1823-
# If the conftest dir is not under rootpath, it will never get
1824-
# a Directory collector — assign it to Session now.
1846+
# Conftests outside of the rootdir never get a Directory collector.
18251847
try:
18261848
conftest_dir.relative_to(rootpath)
18271849
except ValueError:
18281850
orphaned.append((conftest_dir, plugin))
1851+
continue
1852+
# The rootdir conftest used to have an empty baseid (matches all).
1853+
if conftest_dir == rootpath:
1854+
orphaned.append((conftest_dir, plugin))
18291855
for conftest_dir, plugin in orphaned:
18301856
del self._pending_conftests[conftest_dir]
18311857
self.parsefactories(holder=plugin, node=session)

testing/test_conftest.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -911,6 +911,54 @@ def test_uses_ancestor(ancestor_fixture):
911911
result.stdout.fnmatch_lines(["*test_uses_ancestor*PASSED*", "*1 passed*"])
912912

913913

914+
def test_rootdir_conftest_visible_outside_rootdir(pytester: Pytester) -> None:
915+
"""A conftest located in the rootdir provides fixtures to items that are
916+
collected from *outside* the rootdir.
917+
918+
Before pytest 9.1 the rootdir conftest got an empty baseid (which matches
919+
every collected item), so its fixtures were visible session-wide. The
920+
node-based scoping introduced in #14098 (pytest 9.1) inadvertently scoped
921+
it to its own Directory node, making it invisible to items collected from
922+
a parent/sibling of the rootdir. Regression test for #14683.
923+
924+
Layout::
925+
926+
project/ <- pytest invoked here
927+
xclim/ <- collected (``--doctest-modules xclim``)
928+
testing/ <- rootdir (``--rootdir xclim/testing``)
929+
conftest.py <- defines a fixture
930+
core/
931+
test_it.py <- collected from outside rootdir
932+
"""
933+
root = pytester.path
934+
testing = root / "xclim" / "testing"
935+
testing.mkdir(parents=True)
936+
testing.joinpath("conftest.py").write_text(
937+
textwrap.dedent("""\
938+
import pytest
939+
940+
@pytest.fixture
941+
def rootdir_fixture():
942+
return "from-rootdir"
943+
"""),
944+
encoding="utf-8",
945+
)
946+
core = root / "xclim" / "core"
947+
core.mkdir()
948+
core.joinpath("test_it.py").write_text(
949+
textwrap.dedent("""\
950+
def test_uses_rootdir(rootdir_fixture):
951+
assert rootdir_fixture == "from-rootdir"
952+
"""),
953+
encoding="utf-8",
954+
)
955+
956+
result = pytester.runpytest(
957+
"--rootdir", str(testing), "--doctest-modules", "xclim", "-v"
958+
)
959+
result.stdout.fnmatch_lines(["*test_uses_rootdir*PASSED*", "*1 passed*"])
960+
961+
914962
def test_fixture_closure_order_independence_with_parametrize(
915963
pytester: Pytester,
916964
) -> None:

testing/test_doctest.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1573,6 +1573,59 @@ def foo():
15731573
reprec = pytester.inline_run(p, "--doctest-modules")
15741574
reprec.assertoutcome(passed=1)
15751575

1576+
def test_namespace_fixture_from_rootdir_when_modules_outside_rootdir(
1577+
self, pytester: Pytester
1578+
) -> None:
1579+
"""doctest_namespace injection from a conftest in the rootdir still
1580+
applies when the doctest modules are collected from outside the
1581+
rootdir.
1582+
1583+
Regression test for #14683: setting ``--rootdir`` to a subdirectory
1584+
while collecting modules from a parent directory made the rootdir
1585+
conftest's ``doctest_namespace`` injection invisible.
1586+
"""
1587+
testing = pytester.path / "xclim" / "testing"
1588+
testing.mkdir(parents=True)
1589+
testing.joinpath("conftest.py").write_text(
1590+
textwrap.dedent(
1591+
"""\
1592+
import pytest
1593+
1594+
@pytest.fixture(autouse=True, scope="session")
1595+
def add_var(doctest_namespace):
1596+
doctest_namespace["my_var"] = 42
1597+
"""
1598+
),
1599+
encoding="utf-8",
1600+
)
1601+
core = pytester.path / "xclim" / "core"
1602+
core.mkdir()
1603+
core.joinpath("mod.py").write_text(
1604+
textwrap.dedent(
1605+
"""\
1606+
def func():
1607+
'''
1608+
>>> my_var
1609+
42
1610+
'''
1611+
"""
1612+
),
1613+
encoding="utf-8",
1614+
)
1615+
1616+
# The conftest is both the config file and at the rootdir, and the
1617+
# collection argument (``xclim``) is a *parent* of the rootdir
1618+
# (``xclim/testing``) -- the exact setup from #14683.
1619+
result = pytester.runpytest(
1620+
"--rootdir",
1621+
str(testing),
1622+
"--config-file",
1623+
str(testing / "conftest.py"),
1624+
"--doctest-modules",
1625+
"xclim",
1626+
)
1627+
result.assert_outcomes(passed=1)
1628+
15761629

15771630
class TestDoctestReportingOption:
15781631
def _run_doctest_report(self, pytester, format):

0 commit comments

Comments
 (0)