diff --git a/changelog/14683.bugfix.rst b/changelog/14683.bugfix.rst new file mode 100644 index 00000000000..b10bc5d415f --- /dev/null +++ b/changelog/14683.bugfix.rst @@ -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. diff --git a/src/_pytest/fixtures.py b/src/_pytest/fixtures.py index f4ca2eac455..f7473d9b8db 100644 --- a/src/_pytest/fixtures.py +++ b/src/_pytest/fixtures.py @@ -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) diff --git a/testing/test_conftest.py b/testing/test_conftest.py index 23a0db81c39..792190828fb 100644 --- a/testing/test_conftest.py +++ b/testing/test_conftest.py @@ -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") diff --git a/testing/test_doctest.py b/testing/test_doctest.py index 9c788d0fc41..bae0f59409f 100644 --- a/testing/test_doctest.py +++ b/testing/test_doctest.py @@ -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):