Different fix for conftest loading

--- Current main

In current main (before pervious commit), calls to gethookproxy/ihook
are the trigger for loading non-initial conftests. This basically means
that conftest loading is done almost as a random side-effect,
uncontrolled and very non-obvious. And it also dashes any hope of making
gethookproxy faster (gethookproxy shows up prominently in pytest
profiles).

I've wanted to improve this for a while, #11268 was the latest step
towards that.

--- PR before change

In this PR, I ran into a problem.

Previously, Session and Package did all of the directory traversals
inside of their collect, which loaded the conftests as a side effect. If
the conftest loading failed, it will occur inside of the collect() and
cause it to be reported as a failure.

Now I've changed things such that Session.collect and Package.collect no
longer recurse, but just collect their immediate descendants, and
genitems does the recursive expansion work.

The problem though is that genitems() doesn't run inside of specific
collector's collect context. So when it loads a conftest, and the
conftest loading fails, the exception isn't handled by any CollectReport
and causes an internal error instead.

The way I've fixed this problem is by loading the conftests eagerly in a
pytest_collect_directory post-wrapper, but only during genitems to make
sure the directory is actually selected.

This solution in turn caused the conftests to be collected too early;
specifically, the plugins are loaded during the parent's collect(), one
after the other as the directory entries are collected. So when the
ihook is hoisted out of the loop, new plugins are loaded inside the
loop, and due to the way the hook proxy works, they are added to the
ihook even though they're supposed to be scoped to the child collectors.
So no hoisting.

--- PR after change

Now I've come up with a better solution: since now the collection tree
actually reflects the filesystem tree, what we really want is to load
the conftest of a directory right before we run its collect(). A
conftest can affect a directory's collect() (e.g. with a
pytest_ignore_collect hookimpl), but it cannot affect how the directory
node itself is collected. So I just moved the conftest loading to be
done right before calling collect, but still inside the CollectReport
context.

This allows the hoisting, and also removes conftest loading from
gethookproxy since it's no longer necessary. And it will probably enable
further cleanups. So I'm happy with it.
This commit is contained in:
Ran Benita 2023-12-05 23:07:06 +02:00
parent 385796ba49
commit e1c66ab0ad
3 changed files with 20 additions and 42 deletions

View File

@ -12,7 +12,6 @@ from typing import Callable
from typing import Dict from typing import Dict
from typing import final from typing import final
from typing import FrozenSet from typing import FrozenSet
from typing import Generator
from typing import Iterable from typing import Iterable
from typing import Iterator from typing import Iterator
from typing import List from typing import List
@ -502,11 +501,11 @@ class Dir(nodes.Directory):
config = self.config config = self.config
col: Optional[nodes.Collector] col: Optional[nodes.Collector]
cols: Sequence[nodes.Collector] cols: Sequence[nodes.Collector]
ihook = self.ihook
for direntry in scandir(self.path): for direntry in scandir(self.path):
if direntry.is_dir(): if direntry.is_dir():
if direntry.name == "__pycache__": if direntry.name == "__pycache__":
continue continue
ihook = self.ihook
path = Path(direntry.path) path = Path(direntry.path)
if not self.session.isinitpath(path, with_parents=True): if not self.session.isinitpath(path, with_parents=True):
if ihook.pytest_ignore_collect(collection_path=path, config=config): if ihook.pytest_ignore_collect(collection_path=path, config=config):
@ -516,7 +515,6 @@ class Dir(nodes.Directory):
yield col yield col
elif direntry.is_file(): elif direntry.is_file():
ihook = self.ihook
path = Path(direntry.path) path = Path(direntry.path)
if not self.session.isinitpath(path): if not self.session.isinitpath(path):
if ihook.pytest_ignore_collect(collection_path=path, config=config): if ihook.pytest_ignore_collect(collection_path=path, config=config):
@ -559,7 +557,6 @@ class Session(nodes.Collector):
self._initialpaths_with_parents: FrozenSet[Path] = frozenset() self._initialpaths_with_parents: FrozenSet[Path] = frozenset()
self._notfound: List[Tuple[str, Sequence[nodes.Collector]]] = [] self._notfound: List[Tuple[str, Sequence[nodes.Collector]]] = []
self._initial_parts: List[Tuple[Path, List[str]]] = [] self._initial_parts: List[Tuple[Path, List[str]]] = []
self._in_genitems = False
self._collection_cache: Dict[nodes.Collector, CollectReport] = {} self._collection_cache: Dict[nodes.Collector, CollectReport] = {}
self.items: List[nodes.Item] = [] self.items: List[nodes.Item] = []
@ -612,29 +609,6 @@ class Session(nodes.Collector):
pytest_collectreport = pytest_runtest_logreport pytest_collectreport = pytest_runtest_logreport
@hookimpl(wrapper=True)
def pytest_collect_directory(
self,
) -> Generator[None, Optional[nodes.Collector], Optional[nodes.Collector]]:
col = yield
# Eagerly load conftests for the directory.
# This is needed because a conftest error needs to happen while
# collecting a collector, so it is caught by its CollectReport.
# Without this, the conftests are loaded inside of genitems itself
# which leads to an internal error.
# This should only be done for genitems; if done unconditionally, it
# will load conftests for non-selected directories which is to be
# avoided.
if self._in_genitems and col is not None:
self.config.pluginmanager._loadconftestmodules(
col.path,
self.config.getoption("importmode"),
rootpath=self.config.rootpath,
)
return col
def isinitpath( def isinitpath(
self, self,
path: Union[str, "os.PathLike[str]"], path: Union[str, "os.PathLike[str]"],
@ -665,15 +639,6 @@ class Session(nodes.Collector):
pm = self.config.pluginmanager pm = self.config.pluginmanager
# Check if we have the common case of running # Check if we have the common case of running
# hooks with all conftest.py files. # hooks with all conftest.py files.
#
# TODO: pytest relies on this call to load non-initial conftests. This
# is incidental. It will be better to load conftests at a more
# well-defined place.
pm._loadconftestmodules(
path,
self.config.getoption("importmode"),
rootpath=self.config.rootpath,
)
my_conftestmodules = pm._getconftestmodules(path) my_conftestmodules = pm._getconftestmodules(path)
remove_mods = pm._conftest_plugins.difference(my_conftestmodules) remove_mods = pm._conftest_plugins.difference(my_conftestmodules)
proxy: pluggy.HookRelay proxy: pluggy.HookRelay
@ -754,7 +719,6 @@ class Session(nodes.Collector):
self._notfound = [] self._notfound = []
self._initial_parts = [] self._initial_parts = []
self._in_genitems = False
self._collection_cache = {} self._collection_cache = {}
self.items = [] self.items = []
items: Sequence[Union[nodes.Item, nodes.Collector]] = self.items items: Sequence[Union[nodes.Item, nodes.Collector]] = self.items
@ -789,7 +753,6 @@ class Session(nodes.Collector):
raise UsageError(*errors) raise UsageError(*errors)
self._in_genitems = True
if not genitems: if not genitems:
items = rep.result items = rep.result
else: else:
@ -804,7 +767,6 @@ class Session(nodes.Collector):
finally: finally:
self._notfound = [] self._notfound = []
self._initial_parts = [] self._initial_parts = []
self._in_genitems = False
self._collection_cache = {} self._collection_cache = {}
hook.pytest_collection_finish(session=self) hook.pytest_collection_finish(session=self)

View File

@ -731,12 +731,12 @@ class Package(nodes.Directory):
config = self.config config = self.config
col: Optional[nodes.Collector] col: Optional[nodes.Collector]
cols: Sequence[nodes.Collector] cols: Sequence[nodes.Collector]
ihook = self.ihook
for direntry in scandir(self.path, sort_key): for direntry in scandir(self.path, sort_key):
if direntry.is_dir(): if direntry.is_dir():
if direntry.name == "__pycache__": if direntry.name == "__pycache__":
continue continue
path = Path(direntry.path) path = Path(direntry.path)
ihook = self.ihook
if not self.session.isinitpath(path, with_parents=True): if not self.session.isinitpath(path, with_parents=True):
if ihook.pytest_ignore_collect(collection_path=path, config=config): if ihook.pytest_ignore_collect(collection_path=path, config=config):
continue continue
@ -746,7 +746,6 @@ class Package(nodes.Directory):
elif direntry.is_file(): elif direntry.is_file():
path = Path(direntry.path) path = Path(direntry.path)
ihook = self.ihook
if not self.session.isinitpath(path): if not self.session.isinitpath(path):
if ihook.pytest_ignore_collect(collection_path=path, config=config): if ihook.pytest_ignore_collect(collection_path=path, config=config):
continue continue

View File

@ -28,6 +28,7 @@ from _pytest._code.code import TerminalRepr
from _pytest.config.argparsing import Parser from _pytest.config.argparsing import Parser
from _pytest.deprecated import check_ispytest from _pytest.deprecated import check_ispytest
from _pytest.nodes import Collector from _pytest.nodes import Collector
from _pytest.nodes import Directory
from _pytest.nodes import Item from _pytest.nodes import Item
from _pytest.nodes import Node from _pytest.nodes import Node
from _pytest.outcomes import Exit from _pytest.outcomes import Exit
@ -368,7 +369,23 @@ def pytest_runtest_makereport(item: Item, call: CallInfo[None]) -> TestReport:
def pytest_make_collect_report(collector: Collector) -> CollectReport: def pytest_make_collect_report(collector: Collector) -> CollectReport:
call = CallInfo.from_call(lambda: list(collector.collect()), "collect") def collect() -> List[Union[Item, Collector]]:
# Before collecting, if this is a Directory, load the conftests.
# If a conftest import fails to load, it is considered a collection
# error of the Directory collector. This is why it's done inside of the
# CallInfo wrapper.
#
# Note: initial conftests are loaded early, not here.
if isinstance(collector, Directory):
collector.config.pluginmanager._loadconftestmodules(
collector.path,
collector.config.getoption("importmode"),
rootpath=collector.config.rootpath,
)
return list(collector.collect())
call = CallInfo.from_call(collect, "collect")
longrepr: Union[None, Tuple[str, int, str], str, TerminalRepr] = None longrepr: Union[None, Tuple[str, int, str], str, TerminalRepr] = None
if not call.excinfo: if not call.excinfo:
outcome: Literal["passed", "skipped", "failed"] = "passed" outcome: Literal["passed", "skipped", "failed"] = "passed"