Skip to content

Commit e1c66ab

Browse files
committed
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.
1 parent 385796b commit e1c66ab

File tree

3 files changed

+20
-42
lines changed

3 files changed

+20
-42
lines changed

src/_pytest/main.py

+1-39
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@
1212
from typing import Dict
1313
from typing import final
1414
from typing import FrozenSet
15-
from typing import Generator
1615
from typing import Iterable
1716
from typing import Iterator
1817
from typing import List
@@ -502,11 +501,11 @@ def collect(self) -> Iterable[Union[nodes.Item, nodes.Collector]]:
502501
config = self.config
503502
col: Optional[nodes.Collector]
504503
cols: Sequence[nodes.Collector]
504+
ihook = self.ihook
505505
for direntry in scandir(self.path):
506506
if direntry.is_dir():
507507
if direntry.name == "__pycache__":
508508
continue
509-
ihook = self.ihook
510509
path = Path(direntry.path)
511510
if not self.session.isinitpath(path, with_parents=True):
512511
if ihook.pytest_ignore_collect(collection_path=path, config=config):
@@ -516,7 +515,6 @@ def collect(self) -> Iterable[Union[nodes.Item, nodes.Collector]]:
516515
yield col
517516

518517
elif direntry.is_file():
519-
ihook = self.ihook
520518
path = Path(direntry.path)
521519
if not self.session.isinitpath(path):
522520
if ihook.pytest_ignore_collect(collection_path=path, config=config):
@@ -559,7 +557,6 @@ def __init__(self, config: Config) -> None:
559557
self._initialpaths_with_parents: FrozenSet[Path] = frozenset()
560558
self._notfound: List[Tuple[str, Sequence[nodes.Collector]]] = []
561559
self._initial_parts: List[Tuple[Path, List[str]]] = []
562-
self._in_genitems = False
563560
self._collection_cache: Dict[nodes.Collector, CollectReport] = {}
564561
self.items: List[nodes.Item] = []
565562

@@ -612,29 +609,6 @@ def pytest_runtest_logreport(
612609

613610
pytest_collectreport = pytest_runtest_logreport
614611

615-
@hookimpl(wrapper=True)
616-
def pytest_collect_directory(
617-
self,
618-
) -> Generator[None, Optional[nodes.Collector], Optional[nodes.Collector]]:
619-
col = yield
620-
621-
# Eagerly load conftests for the directory.
622-
# This is needed because a conftest error needs to happen while
623-
# collecting a collector, so it is caught by its CollectReport.
624-
# Without this, the conftests are loaded inside of genitems itself
625-
# which leads to an internal error.
626-
# This should only be done for genitems; if done unconditionally, it
627-
# will load conftests for non-selected directories which is to be
628-
# avoided.
629-
if self._in_genitems and col is not None:
630-
self.config.pluginmanager._loadconftestmodules(
631-
col.path,
632-
self.config.getoption("importmode"),
633-
rootpath=self.config.rootpath,
634-
)
635-
636-
return col
637-
638612
def isinitpath(
639613
self,
640614
path: Union[str, "os.PathLike[str]"],
@@ -665,15 +639,6 @@ def gethookproxy(self, fspath: "os.PathLike[str]") -> pluggy.HookRelay:
665639
pm = self.config.pluginmanager
666640
# Check if we have the common case of running
667641
# hooks with all conftest.py files.
668-
#
669-
# TODO: pytest relies on this call to load non-initial conftests. This
670-
# is incidental. It will be better to load conftests at a more
671-
# well-defined place.
672-
pm._loadconftestmodules(
673-
path,
674-
self.config.getoption("importmode"),
675-
rootpath=self.config.rootpath,
676-
)
677642
my_conftestmodules = pm._getconftestmodules(path)
678643
remove_mods = pm._conftest_plugins.difference(my_conftestmodules)
679644
proxy: pluggy.HookRelay
@@ -754,7 +719,6 @@ def perform_collect( # noqa: F811
754719

755720
self._notfound = []
756721
self._initial_parts = []
757-
self._in_genitems = False
758722
self._collection_cache = {}
759723
self.items = []
760724
items: Sequence[Union[nodes.Item, nodes.Collector]] = self.items
@@ -789,7 +753,6 @@ def perform_collect( # noqa: F811
789753

790754
raise UsageError(*errors)
791755

792-
self._in_genitems = True
793756
if not genitems:
794757
items = rep.result
795758
else:
@@ -804,7 +767,6 @@ def perform_collect( # noqa: F811
804767
finally:
805768
self._notfound = []
806769
self._initial_parts = []
807-
self._in_genitems = False
808770
self._collection_cache = {}
809771
hook.pytest_collection_finish(session=self)
810772

src/_pytest/python.py

+1-2
Original file line numberDiff line numberDiff line change
@@ -731,12 +731,12 @@ def sort_key(entry: "os.DirEntry[str]") -> object:
731731
config = self.config
732732
col: Optional[nodes.Collector]
733733
cols: Sequence[nodes.Collector]
734+
ihook = self.ihook
734735
for direntry in scandir(self.path, sort_key):
735736
if direntry.is_dir():
736737
if direntry.name == "__pycache__":
737738
continue
738739
path = Path(direntry.path)
739-
ihook = self.ihook
740740
if not self.session.isinitpath(path, with_parents=True):
741741
if ihook.pytest_ignore_collect(collection_path=path, config=config):
742742
continue
@@ -746,7 +746,6 @@ def sort_key(entry: "os.DirEntry[str]") -> object:
746746

747747
elif direntry.is_file():
748748
path = Path(direntry.path)
749-
ihook = self.ihook
750749
if not self.session.isinitpath(path):
751750
if ihook.pytest_ignore_collect(collection_path=path, config=config):
752751
continue

src/_pytest/runner.py

+18-1
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
from _pytest.config.argparsing import Parser
2929
from _pytest.deprecated import check_ispytest
3030
from _pytest.nodes import Collector
31+
from _pytest.nodes import Directory
3132
from _pytest.nodes import Item
3233
from _pytest.nodes import Node
3334
from _pytest.outcomes import Exit
@@ -368,7 +369,23 @@ def pytest_runtest_makereport(item: Item, call: CallInfo[None]) -> TestReport:
368369

369370

370371
def pytest_make_collect_report(collector: Collector) -> CollectReport:
371-
call = CallInfo.from_call(lambda: list(collector.collect()), "collect")
372+
def collect() -> List[Union[Item, Collector]]:
373+
# Before collecting, if this is a Directory, load the conftests.
374+
# If a conftest import fails to load, it is considered a collection
375+
# error of the Directory collector. This is why it's done inside of the
376+
# CallInfo wrapper.
377+
#
378+
# Note: initial conftests are loaded early, not here.
379+
if isinstance(collector, Directory):
380+
collector.config.pluginmanager._loadconftestmodules(
381+
collector.path,
382+
collector.config.getoption("importmode"),
383+
rootpath=collector.config.rootpath,
384+
)
385+
386+
return list(collector.collect())
387+
388+
call = CallInfo.from_call(collect, "collect")
372389
longrepr: Union[None, Tuple[str, int, str], str, TerminalRepr] = None
373390
if not call.excinfo:
374391
outcome: Literal["passed", "skipped", "failed"] = "passed"

0 commit comments

Comments
 (0)