From f411c8d6d786f6fe53e32eb5c132312da10e2182 Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Fri, 30 Jun 2023 10:56:58 +0300 Subject: [PATCH 1/3] main: add `with_parents` parameter to `isinitpath` Will be used in upcoming commit. --- src/_pytest/main.py | 28 ++++++++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/src/_pytest/main.py b/src/_pytest/main.py index 5cee8e89b..f7b34ded8 100644 --- a/src/_pytest/main.py +++ b/src/_pytest/main.py @@ -499,6 +499,7 @@ class Session(nodes.FSCollector): self.shouldfail: Union[bool, str] = False self.trace = config.trace.root.get("collection") self._initialpaths: FrozenSet[Path] = frozenset() + self._initialpaths_with_parents: FrozenSet[Path] = frozenset() self._bestrelpathcache: Dict[Path, str] = _bestrelpath_cache(config.rootpath) @@ -549,10 +550,29 @@ class Session(nodes.FSCollector): pytest_collectreport = pytest_runtest_logreport - def isinitpath(self, path: Union[str, "os.PathLike[str]"]) -> bool: + def isinitpath( + self, + path: Union[str, "os.PathLike[str]"], + *, + with_parents: bool = False, + ) -> bool: + """Is path an initial path? + + An initial path is a path pytest starts with, e.g. given on the command + line. + + :param with_parents: + If set, also return True if the path is a parent of an initial path. + + .. versionchanged:: 8.0 + Added the ``with_parents`` parameter. + """ # Optimization: Path(Path(...)) is much slower than isinstance. path_ = path if isinstance(path, Path) else Path(path) - return path_ in self._initialpaths + if with_parents: + return path_ in self._initialpaths_with_parents + else: + return path_ in self._initialpaths def gethookproxy(self, fspath: "os.PathLike[str]") -> pluggy.HookRelay: # Optimization: Path(Path(...)) is much slower than isinstance. @@ -667,6 +687,7 @@ class Session(nodes.FSCollector): items: Sequence[Union[nodes.Item, nodes.Collector]] = self.items try: initialpaths: List[Path] = [] + initialpaths_with_parents: List[Path] = [] for arg in args: fspath, parts = resolve_collection_argument( self.config.invocation_params.dir, @@ -675,7 +696,10 @@ class Session(nodes.FSCollector): ) self._initial_parts.append((fspath, parts)) initialpaths.append(fspath) + initialpaths_with_parents.append(fspath) + initialpaths_with_parents.extend(fspath.parents) self._initialpaths = frozenset(initialpaths) + self._initialpaths_with_parents = frozenset(initialpaths_with_parents) rep = collect_one_node(self) self.ihook.pytest_collectreport(report=rep) self.trace.root.indent -= 1 From 385796ba494e7ae65d55892d4a358b371ac7a6b6 Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Fri, 2 Jun 2023 16:03:39 +0300 Subject: [PATCH 2/3] Rework Session and Package collection Fix #7777. --- changelog/7777.breaking.rst | 90 ++++ doc/en/deprecations.rst | 85 ++++ doc/en/example/conftest.py | 2 +- doc/en/example/customdirectory.rst | 77 ++++ doc/en/example/customdirectory/conftest.py | 28 ++ doc/en/example/customdirectory/pytest.ini | 0 .../customdirectory/tests/manifest.json | 6 + .../customdirectory/tests/test_first.py | 3 + .../customdirectory/tests/test_second.py | 3 + .../customdirectory/tests/test_third.py | 3 + doc/en/example/index.rst | 1 + doc/en/reference/reference.rst | 14 + src/_pytest/cacheprovider.py | 4 +- src/_pytest/config/__init__.py | 2 - src/_pytest/hookspec.py | 24 ++ src/_pytest/main.py | 398 +++++++++++------- src/_pytest/mark/__init__.py | 13 +- src/_pytest/nodes.py | 18 + src/_pytest/pathlib.py | 10 +- src/_pytest/python.py | 115 +++-- src/pytest/__init__.py | 4 + testing/acceptance_test.py | 5 +- .../customdirectory/conftest.py | 22 + .../customdirectory/pytest.ini | 0 .../customdirectory/tests/manifest.json | 6 + .../customdirectory/tests/test_first.py | 3 + .../customdirectory/tests/test_second.py | 3 + .../customdirectory/tests/test_third.py | 3 + testing/python/collect.py | 105 +++++ testing/python/metafunc.py | 20 +- testing/test_assertion.py | 10 +- testing/test_cacheprovider.py | 36 +- testing/test_collection.py | 107 +++-- testing/test_config.py | 3 +- testing/test_mark.py | 35 +- testing/test_reports.py | 6 +- testing/test_runner.py | 2 +- testing/test_session.py | 23 +- testing/test_terminal.py | 31 +- 39 files changed, 985 insertions(+), 335 deletions(-) create mode 100644 changelog/7777.breaking.rst create mode 100644 doc/en/example/customdirectory.rst create mode 100644 doc/en/example/customdirectory/conftest.py create mode 100644 doc/en/example/customdirectory/pytest.ini create mode 100644 doc/en/example/customdirectory/tests/manifest.json create mode 100644 doc/en/example/customdirectory/tests/test_first.py create mode 100644 doc/en/example/customdirectory/tests/test_second.py create mode 100644 doc/en/example/customdirectory/tests/test_third.py create mode 100644 testing/example_scripts/customdirectory/conftest.py create mode 100644 testing/example_scripts/customdirectory/pytest.ini create mode 100644 testing/example_scripts/customdirectory/tests/manifest.json create mode 100644 testing/example_scripts/customdirectory/tests/test_first.py create mode 100644 testing/example_scripts/customdirectory/tests/test_second.py create mode 100644 testing/example_scripts/customdirectory/tests/test_third.py diff --git a/changelog/7777.breaking.rst b/changelog/7777.breaking.rst new file mode 100644 index 000000000..d38fea330 --- /dev/null +++ b/changelog/7777.breaking.rst @@ -0,0 +1,90 @@ +Added a new :class:`pytest.Directory` base collection node, which all collector nodes for filesystem directories are expected to subclass. +This is analogous to the existing :class:`pytest.File` for file nodes. + +Changed :class:`pytest.Package` to be a subclass of :class:`pytest.Directory`. +A ``Package`` represents a filesystem directory which is a Python package, +i.e. contains an ``__init__.py`` file. + +:class:`pytest.Package` now only collects files in its own directory; previously it collected recursively. +Sub-directories are collected as sub-collector nodes, thus creating a collection tree which mirrors the filesystem hierarchy. + +Added a new :class:`pytest.Dir` concrete collection node, a subclass of :class:`pytest.Directory`. +This node represents a filesystem directory, which is not a :class:`pytest.Package`, +i.e. does not contain an ``__init__.py`` file. +Similarly to ``Package``, it only collects the files in its own directory, +while collecting sub-directories as sub-collector nodes. + +Added a new hook :hook:`pytest_collect_directory`, +which is called by filesystem-traversing collector nodes, +such as :class:`pytest.Session`, :class:`pytest.Dir` and :class:`pytest.Package`, +to create a collector node for a sub-directory. +It is expected to return a subclass of :class:`pytest.Directory`. +This hook allows plugins to :ref:`customize the collection of directories `. + +:class:`pytest.Session` now only collects the initial arguments, without recursing into directories. +This work is now done by the :func:`recursive expansion process ` of directory collector nodes. + +:attr:`session.name ` is now ``""``; previously it was the rootdir directory name. +This matches :attr:`session.nodeid <_pytest.nodes.Node.nodeid>` which has always been `""`. + +Files and directories are now collected in alphabetical order jointly, unless changed by a plugin. +Previously, files were collected before directories. + +The collection tree now contains directories/packages up to the :ref:`rootdir `, +for initial arguments that are found within the rootdir. +For files outside the rootdir, only the immediate directory/package is collected -- +note however that collecting from outside the rootdir is discouraged. + +As an example, given the following filesystem tree:: + + myroot/ + pytest.ini + top/ + ├── aaa + │ └── test_aaa.py + ├── test_a.py + ├── test_b + │ ├── __init__.py + │ └── test_b.py + ├── test_c.py + └── zzz + ├── __init__.py + └── test_zzz.py + +the collection tree, as shown by `pytest --collect-only top/` but with the otherwise-hidden :class:`~pytest.Session` node added for clarity, +is now the following:: + + + + + + + + + + + + + + + + + + +Previously, it was:: + + + + + + + + + + + + + + + +Code/plugins which rely on a specific shape of the collection tree might need to update. diff --git a/doc/en/deprecations.rst b/doc/en/deprecations.rst index ad051fc62..caa7cb3e7 100644 --- a/doc/en/deprecations.rst +++ b/doc/en/deprecations.rst @@ -495,6 +495,91 @@ an appropriate period of deprecation has passed. Some breaking changes which could not be deprecated are also listed. +Collection changes in pytest 8 +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Added a new :class:`pytest.Directory` base collection node, which all collector nodes for filesystem directories are expected to subclass. +This is analogous to the existing :class:`pytest.File` for file nodes. + +Changed :class:`pytest.Package` to be a subclass of :class:`pytest.Directory`. +A ``Package`` represents a filesystem directory which is a Python package, +i.e. contains an ``__init__.py`` file. + +:class:`pytest.Package` now only collects files in its own directory; previously it collected recursively. +Sub-directories are collected as sub-collector nodes, thus creating a collection tree which mirrors the filesystem hierarchy. + +:attr:`session.name ` is now ``""``; previously it was the rootdir directory name. +This matches :attr:`session.nodeid <_pytest.nodes.Node.nodeid>` which has always been `""`. + +Added a new :class:`pytest.Dir` concrete collection node, a subclass of :class:`pytest.Directory`. +This node represents a filesystem directory, which is not a :class:`pytest.Package`, +i.e. does not contain an ``__init__.py`` file. +Similarly to ``Package``, it only collects the files in its own directory, +while collecting sub-directories as sub-collector nodes. + +Files and directories are now collected in alphabetical order jointly, unless changed by a plugin. +Previously, files were collected before directories. + +The collection tree now contains directories/packages up to the :ref:`rootdir `, +for initial arguments that are found within the rootdir. +For files outside the rootdir, only the immediate directory/package is collected -- +note however that collecting from outside the rootdir is discouraged. + +As an example, given the following filesystem tree:: + + myroot/ + pytest.ini + top/ + ├── aaa + │ └── test_aaa.py + ├── test_a.py + ├── test_b + │ ├── __init__.py + │ └── test_b.py + ├── test_c.py + └── zzz + ├── __init__.py + └── test_zzz.py + +the collection tree, as shown by `pytest --collect-only top/` but with the otherwise-hidden :class:`~pytest.Session` node added for clarity, +is now the following:: + + + + + + + + + + + + + + + + + + +Previously, it was:: + + + + + + + + + + + + + + + +Code/plugins which rely on a specific shape of the collection tree might need to update. + + :class:`pytest.Package` is no longer a :class:`pytest.Module` or :class:`pytest.File` ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/doc/en/example/conftest.py b/doc/en/example/conftest.py index f905738c4..66e70f14d 100644 --- a/doc/en/example/conftest.py +++ b/doc/en/example/conftest.py @@ -1 +1 @@ -collect_ignore = ["nonpython"] +collect_ignore = ["nonpython", "customdirectory"] diff --git a/doc/en/example/customdirectory.rst b/doc/en/example/customdirectory.rst new file mode 100644 index 000000000..1e4d7e370 --- /dev/null +++ b/doc/en/example/customdirectory.rst @@ -0,0 +1,77 @@ +.. _`custom directory collectors`: + +Using a custom directory collector +==================================================== + +By default, pytest collects directories using :class:`pytest.Package`, for directories with ``__init__.py`` files, +and :class:`pytest.Dir` for other directories. +If you want to customize how a directory is collected, you can write your own :class:`pytest.Directory` collector, +and use :hook:`pytest_collect_directory` to hook it up. + +.. _`directory manifest plugin`: + +A basic example for a directory manifest file +-------------------------------------------------------------- + +Suppose you want to customize how collection is done on a per-directory basis. +Here is an example ``conftest.py`` plugin that allows directories to contain a ``manifest.json`` file, +which defines how the collection should be done for the directory. +In this example, only a simple list of files is supported, +however you can imagine adding other keys, such as exclusions and globs. + +.. include:: customdirectory/conftest.py + :literal: + +You can create a ``manifest.json`` file and some test files: + +.. include:: customdirectory/tests/manifest.json + :literal: + +.. include:: customdirectory/tests/test_first.py + :literal: + +.. include:: customdirectory/tests/test_second.py + :literal: + +.. include:: customdirectory/tests/test_third.py + :literal: + +An you can now execute the test specification: + +.. code-block:: pytest + + customdirectory $ pytest + =========================== test session starts ============================ + platform linux -- Python 3.x.y, pytest-8.x.y, pluggy-1.x.y + rootdir: /home/sweet/project/customdirectory + configfile: pytest.ini + collected 2 items + + tests/test_first.py . [ 50%] + tests/test_second.py . [100%] + + ============================ 2 passed in 0.12s ============================= + +.. regendoc:wipe + +Notice how ``test_three.py`` was not executed, because it is not listed in the manifest. + +You can verify that your custom collector appears in the collection tree: + +.. code-block:: pytest + + customdirectory $ pytest --collect-only + =========================== test session starts ============================ + platform linux -- Python 3.x.y, pytest-8.x.y, pluggy-1.x.y + rootdir: /home/sweet/project/customdirectory + configfile: pytest.ini + collected 2 items + + + + + + + + + ======================== 2 tests collected in 0.12s ======================== diff --git a/doc/en/example/customdirectory/conftest.py b/doc/en/example/customdirectory/conftest.py new file mode 100644 index 000000000..350893cab --- /dev/null +++ b/doc/en/example/customdirectory/conftest.py @@ -0,0 +1,28 @@ +# content of conftest.py +import json + +import pytest + + +class ManifestDirectory(pytest.Directory): + def collect(self): + # The standard pytest behavior is to loop over all `test_*.py` files and + # call `pytest_collect_file` on each file. This collector instead reads + # the `manifest.json` file and only calls `pytest_collect_file` for the + # files defined there. + manifest_path = self.path / "manifest.json" + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + ihook = self.ihook + for file in manifest["files"]: + yield from ihook.pytest_collect_file( + file_path=self.path / file, parent=self + ) + + +@pytest.hookimpl +def pytest_collect_directory(path, parent): + # Use our custom collector for directories containing a `mainfest.json` file. + if path.joinpath("manifest.json").is_file(): + return ManifestDirectory.from_parent(parent=parent, path=path) + # Otherwise fallback to the standard behavior. + return None diff --git a/doc/en/example/customdirectory/pytest.ini b/doc/en/example/customdirectory/pytest.ini new file mode 100644 index 000000000..e69de29bb diff --git a/doc/en/example/customdirectory/tests/manifest.json b/doc/en/example/customdirectory/tests/manifest.json new file mode 100644 index 000000000..6ab6d0a52 --- /dev/null +++ b/doc/en/example/customdirectory/tests/manifest.json @@ -0,0 +1,6 @@ +{ + "files": [ + "test_first.py", + "test_second.py" + ] +} diff --git a/doc/en/example/customdirectory/tests/test_first.py b/doc/en/example/customdirectory/tests/test_first.py new file mode 100644 index 000000000..0a78de599 --- /dev/null +++ b/doc/en/example/customdirectory/tests/test_first.py @@ -0,0 +1,3 @@ +# content of test_first.py +def test_1(): + pass diff --git a/doc/en/example/customdirectory/tests/test_second.py b/doc/en/example/customdirectory/tests/test_second.py new file mode 100644 index 000000000..eed724a7d --- /dev/null +++ b/doc/en/example/customdirectory/tests/test_second.py @@ -0,0 +1,3 @@ +# content of test_second.py +def test_2(): + pass diff --git a/doc/en/example/customdirectory/tests/test_third.py b/doc/en/example/customdirectory/tests/test_third.py new file mode 100644 index 000000000..61cf59dc1 --- /dev/null +++ b/doc/en/example/customdirectory/tests/test_third.py @@ -0,0 +1,3 @@ +# content of test_third.py +def test_3(): + pass diff --git a/doc/en/example/index.rst b/doc/en/example/index.rst index 71e855534..e8835aae9 100644 --- a/doc/en/example/index.rst +++ b/doc/en/example/index.rst @@ -32,3 +32,4 @@ The following examples aim at various use cases you might encounter. special pythoncollection nonpython + customdirectory diff --git a/doc/en/reference/reference.rst b/doc/en/reference/reference.rst index d6f942ad0..a360e1612 100644 --- a/doc/en/reference/reference.rst +++ b/doc/en/reference/reference.rst @@ -682,6 +682,8 @@ Collection hooks .. autofunction:: pytest_collection .. hook:: pytest_ignore_collect .. autofunction:: pytest_ignore_collect +.. hook:: pytest_collect_directory +.. autofunction:: pytest_collect_directory .. hook:: pytest_collect_file .. autofunction:: pytest_collect_file .. hook:: pytest_pycollect_makemodule @@ -921,6 +923,18 @@ Config .. autoclass:: pytest.Config() :members: +Dir +~~~ + +.. autoclass:: pytest.Dir() + :members: + +Directory +~~~~~~~~~ + +.. autoclass:: pytest.Directory() + :members: + ExceptionInfo ~~~~~~~~~~~~~ diff --git a/src/_pytest/cacheprovider.py b/src/_pytest/cacheprovider.py index 50a474a29..793e796de 100755 --- a/src/_pytest/cacheprovider.py +++ b/src/_pytest/cacheprovider.py @@ -27,8 +27,8 @@ from _pytest.deprecated import check_ispytest from _pytest.fixtures import fixture from _pytest.fixtures import FixtureRequest from _pytest.main import Session +from _pytest.nodes import Directory from _pytest.nodes import File -from _pytest.python import Package from _pytest.reports import TestReport README_CONTENT = """\ @@ -222,7 +222,7 @@ class LFPluginCollWrapper: self, collector: nodes.Collector ) -> Generator[None, CollectReport, CollectReport]: res = yield - if isinstance(collector, (Session, Package)): + if isinstance(collector, (Session, Directory)): # Sort any lf-paths to the beginning. lf_paths = self.lfplugin._last_failed_paths diff --git a/src/_pytest/config/__init__.py b/src/_pytest/config/__init__.py index adf1bfd9a..e5775546d 100644 --- a/src/_pytest/config/__init__.py +++ b/src/_pytest/config/__init__.py @@ -415,8 +415,6 @@ class PytestPluginManager(PluginManager): # session (#9478), often with the same path, so cache it. self._get_directory = lru_cache(256)(_get_directory) - self._duplicatepaths: Set[Path] = set() - # plugins that were explicitly skipped with pytest.skip # list of (module name, skip reason) # previously we would issue a warning when a plugin was skipped, but diff --git a/src/_pytest/hookspec.py b/src/_pytest/hookspec.py index 8a4e29e67..3c65234da 100644 --- a/src/_pytest/hookspec.py +++ b/src/_pytest/hookspec.py @@ -284,11 +284,35 @@ def pytest_ignore_collect( """ +@hookspec(firstresult=True) +def pytest_collect_directory(path: Path, parent: "Collector") -> "Optional[Collector]": + """Create a :class:`~pytest.Collector` for the given directory, or None if + not relevant. + + .. versionadded:: 8.0 + + For best results, the returned collector should be a subclass of + :class:`~pytest.Directory`, but this is not required. + + The new node needs to have the specified ``parent`` as a parent. + + Stops at first non-None result, see :ref:`firstresult`. + + :param path: The path to analyze. + + See :ref:`custom directory collectors` for a simple example of use of this + hook. + """ + + def pytest_collect_file( file_path: Path, path: "LEGACY_PATH", parent: "Collector" ) -> "Optional[Collector]": """Create a :class:`~pytest.Collector` for the given path, or None if not relevant. + For best results, the returned collector should be a subclass of + :class:`~pytest.File`, but this is not required. + The new node needs to have the specified ``parent`` as a parent. :param file_path: The path to analyze. diff --git a/src/_pytest/main.py b/src/_pytest/main.py index f7b34ded8..e4ca05aac 100644 --- a/src/_pytest/main.py +++ b/src/_pytest/main.py @@ -12,6 +12,8 @@ from typing import Callable from typing import Dict from typing import final from typing import FrozenSet +from typing import Generator +from typing import Iterable from typing import Iterator from typing import List from typing import Literal @@ -19,8 +21,6 @@ from typing import Optional from typing import overload from typing import Sequence from typing import Tuple -from typing import Type -from typing import TYPE_CHECKING from typing import Union import pluggy @@ -41,17 +41,13 @@ from _pytest.pathlib import absolutepath from _pytest.pathlib import bestrelpath from _pytest.pathlib import fnmatch_ex from _pytest.pathlib import safe_exists -from _pytest.pathlib import visit +from _pytest.pathlib import scandir from _pytest.reports import CollectReport from _pytest.reports import TestReport from _pytest.runner import collect_one_node from _pytest.runner import SetupState -if TYPE_CHECKING: - from _pytest.python import Package - - def pytest_addoption(parser: Parser) -> None: parser.addini( "norecursedirs", @@ -414,6 +410,12 @@ def pytest_ignore_collect(collection_path: Path, config: Config) -> Optional[boo return None +def pytest_collect_directory( + path: Path, parent: nodes.Collector +) -> Optional[nodes.Collector]: + return Dir.from_parent(parent, path=path) + + def pytest_collection_modifyitems(items: List[nodes.Item], config: Config) -> None: deselect_prefixes = tuple(config.getoption("deselect") or []) if not deselect_prefixes: @@ -470,7 +472,61 @@ class _bestrelpath_cache(Dict[Path, str]): @final -class Session(nodes.FSCollector): +class Dir(nodes.Directory): + """Collector of files in a file system directory. + + .. versionadded:: 8.0 + + .. note:: + + Python directories with an `__init__.py` file are instead collected by + :class:`~pytest.Package` by default. Both are :class:`~pytest.Directory` + collectors. + """ + + @classmethod + def from_parent( # type: ignore[override] + cls, + parent: nodes.Collector, # type: ignore[override] + *, + path: Path, + ) -> "Dir": + """The public constructor. + + :param parent: The parent collector of this Dir. + :param path: The directory's path. + """ + return super().from_parent(parent=parent, path=path) # type: ignore[no-any-return] + + def collect(self) -> Iterable[Union[nodes.Item, nodes.Collector]]: + config = self.config + col: Optional[nodes.Collector] + cols: Sequence[nodes.Collector] + for direntry in scandir(self.path): + if direntry.is_dir(): + if direntry.name == "__pycache__": + continue + ihook = self.ihook + path = Path(direntry.path) + if not self.session.isinitpath(path, with_parents=True): + if ihook.pytest_ignore_collect(collection_path=path, config=config): + continue + col = ihook.pytest_collect_directory(path=path, parent=self) + if col is not None: + yield col + + elif direntry.is_file(): + ihook = self.ihook + path = Path(direntry.path) + if not self.session.isinitpath(path): + if ihook.pytest_ignore_collect(collection_path=path, config=config): + continue + cols = ihook.pytest_collect_file(file_path=path, parent=self) + yield from cols + + +@final +class Session(nodes.Collector): """The root of the collection tree. ``Session`` collects the initial paths given as arguments to pytest. @@ -486,6 +542,7 @@ class Session(nodes.FSCollector): def __init__(self, config: Config) -> None: super().__init__( + name="", path=config.rootpath, fspath=None, parent=None, @@ -500,6 +557,11 @@ class Session(nodes.FSCollector): self.trace = config.trace.root.get("collection") self._initialpaths: FrozenSet[Path] = frozenset() self._initialpaths_with_parents: FrozenSet[Path] = frozenset() + self._notfound: List[Tuple[str, Sequence[nodes.Collector]]] = [] + self._initial_parts: List[Tuple[Path, List[str]]] = [] + self._in_genitems = False + self._collection_cache: Dict[nodes.Collector, CollectReport] = {} + self.items: List[nodes.Item] = [] self._bestrelpathcache: Dict[Path, str] = _bestrelpath_cache(config.rootpath) @@ -550,6 +612,29 @@ class Session(nodes.FSCollector): 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( self, path: Union[str, "os.PathLike[str]"], @@ -558,7 +643,7 @@ class Session(nodes.FSCollector): ) -> bool: """Is path an initial path? - An initial path is a path pytest starts with, e.g. given on the command + An initial path is a path explicitly given to pytest on the command line. :param with_parents: @@ -600,49 +685,36 @@ class Session(nodes.FSCollector): proxy = self.config.hook return proxy - def _recurse(self, direntry: "os.DirEntry[str]") -> bool: - if direntry.name == "__pycache__": - return False - fspath = Path(direntry.path) - ihook = self.gethookproxy(fspath.parent) - if ihook.pytest_ignore_collect(collection_path=fspath, config=self.config): - return False - return True - - def _collectpackage(self, fspath: Path) -> Optional["Package"]: - from _pytest.python import Package - - ihook = self.gethookproxy(fspath) - if not self.isinitpath(fspath): - if ihook.pytest_ignore_collect(collection_path=fspath, config=self.config): - return None - - pkg: Package = Package.from_parent(self, path=fspath) - return pkg - - def _collectfile( - self, fspath: Path, handle_dupes: bool = True + def _collect_path( + self, + path: Path, + path_cache: Dict[Path, Sequence[nodes.Collector]], ) -> Sequence[nodes.Collector]: - assert ( - fspath.is_file() - ), "{!r} is not a file (isdir={!r}, exists={!r}, islink={!r})".format( - fspath, fspath.is_dir(), fspath.exists(), fspath.is_symlink() - ) - ihook = self.gethookproxy(fspath) - if not self.isinitpath(fspath): - if ihook.pytest_ignore_collect(collection_path=fspath, config=self.config): - return () + """Create a Collector for the given path. - if handle_dupes: - keepduplicates = self.config.getoption("keepduplicates") - if not keepduplicates: - duplicate_paths = self.config.pluginmanager._duplicatepaths - if fspath in duplicate_paths: - return () - else: - duplicate_paths.add(fspath) + `path_cache` makes it so the same Collectors are returned for the same + path. + """ + if path in path_cache: + return path_cache[path] - return ihook.pytest_collect_file(file_path=fspath, parent=self) # type: ignore[no-any-return] + if path.is_dir(): + ihook = self.gethookproxy(path.parent) + col: Optional[nodes.Collector] = ihook.pytest_collect_directory( + path=path, parent=self + ) + cols: Sequence[nodes.Collector] = (col,) if col is not None else () + + elif path.is_file(): + ihook = self.gethookproxy(path) + cols = ihook.pytest_collect_file(file_path=path, parent=self) + + else: + # Broken symlink or invalid/missing file. + cols = () + + path_cache[path] = cols + return cols @overload def perform_collect( @@ -678,12 +750,13 @@ class Session(nodes.FSCollector): self.trace("perform_collect", self, args) self.trace.root.indent += 1 - self._notfound: List[Tuple[str, Sequence[nodes.Collector]]] = [] - self._initial_parts: List[Tuple[Path, List[str]]] = [] - self.items: List[nodes.Item] = [] - hook = self.config.hook + self._notfound = [] + self._initial_parts = [] + self._in_genitems = False + self._collection_cache = {} + self.items = [] items: Sequence[Union[nodes.Item, nodes.Collector]] = self.items try: initialpaths: List[Path] = [] @@ -700,6 +773,7 @@ class Session(nodes.FSCollector): initialpaths_with_parents.extend(fspath.parents) self._initialpaths = frozenset(initialpaths) self._initialpaths_with_parents = frozenset(initialpaths_with_parents) + rep = collect_one_node(self) self.ihook.pytest_collectreport(report=rep) self.trace.root.indent -= 1 @@ -708,12 +782,14 @@ class Session(nodes.FSCollector): for arg, collectors in self._notfound: if collectors: errors.append( - f"not found: {arg}\n(no name {arg!r} in any of {collectors!r})" + f"not found: {arg}\n(no match in any of {collectors!r})" ) else: errors.append(f"found no collectors for {arg}") raise UsageError(*errors) + + self._in_genitems = True if not genitems: items = rep.result else: @@ -726,22 +802,35 @@ class Session(nodes.FSCollector): session=self, config=self.config, items=items ) finally: + self._notfound = [] + self._initial_parts = [] + self._in_genitems = False + self._collection_cache = {} hook.pytest_collection_finish(session=self) - self.testscollected = len(items) + if genitems: + self.testscollected = len(items) + return items + def _collect_one_node( + self, + node: nodes.Collector, + handle_dupes: bool = True, + ) -> Tuple[CollectReport, bool]: + if node in self._collection_cache and handle_dupes: + rep = self._collection_cache[node] + return rep, True + else: + rep = collect_one_node(node) + self._collection_cache[node] = rep + return rep, False + def collect(self) -> Iterator[Union[nodes.Item, nodes.Collector]]: - # Keep track of any collected nodes in here, so we don't duplicate fixtures. - node_cache1: Dict[Path, Sequence[nodes.Collector]] = {} - node_cache2: Dict[Tuple[Type[nodes.Collector], Path], nodes.Collector] = {} - - # Keep track of any collected collectors in matchnodes paths, so they - # are not collected more than once. - matchnodes_cache: Dict[Tuple[Type[nodes.Collector], str], CollectReport] = {} - - # Directories of pkgs with dunder-init files. - pkg_roots: Dict[Path, "Package"] = {} + # This is a cache for the root directories of the initial paths. + # We can't use collection_cache for Session because of its special + # role as the bootstrapping collector. + path_cache: Dict[Path, Sequence[nodes.Collector]] = {} pm = self.config.pluginmanager @@ -749,108 +838,87 @@ class Session(nodes.FSCollector): self.trace("processing argument", (argpath, names)) self.trace.root.indent += 1 - # Start with a Session root, and delve to argpath item (dir or file) - # and stack all Packages found on the way. - for parent in (argpath, *argpath.parents): - if not pm._is_in_confcutdir(argpath): - break - - if parent.is_dir(): - pkginit = parent / "__init__.py" - if pkginit.is_file() and parent not in node_cache1: - pkg = self._collectpackage(parent) - if pkg is not None: - pkg_roots[parent] = pkg - node_cache1[pkg.path] = [pkg] - - # If it's a directory argument, recurse and look for any Subpackages. - # Let the Package collector deal with subnodes, don't collect here. + # resolve_collection_argument() ensures this. if argpath.is_dir(): assert not names, f"invalid arg {(argpath, names)!r}" - if argpath in pkg_roots: - yield pkg_roots[argpath] + # Match the argpath from the root, e.g. + # /a/b/c.py -> [/, /a, /a/b, /a/b/c.py] + paths = [*reversed(argpath.parents), argpath] + # Paths outside of the confcutdir should not be considered, unless + # it's the argpath itself. + while len(paths) > 1 and not pm._is_in_confcutdir(paths[0]): + paths = paths[1:] - for direntry in visit(argpath, self._recurse): - path = Path(direntry.path) - if direntry.is_dir() and self._recurse(direntry): - pkginit = path / "__init__.py" - if pkginit.is_file(): - pkg = self._collectpackage(path) - if pkg is not None: - yield pkg - pkg_roots[path] = pkg + # Start going over the parts from the root, collecting each level + # and discarding all nodes which don't match the level's part. + any_matched_in_initial_part = False + notfound_collectors = [] + work: List[ + Tuple[Union[nodes.Collector, nodes.Item], List[Union[Path, str]]] + ] = [(self, paths + names)] + while work: + matchnode, matchparts = work.pop() - elif direntry.is_file(): - if path.parent in pkg_roots: - # Package handles this file. - continue - for x in self._collectfile(path): - key2 = (type(x), x.path) - if key2 in node_cache2: - yield node_cache2[key2] - else: - node_cache2[key2] = x - yield x - else: - assert argpath.is_file() - - if argpath in node_cache1: - col = node_cache1[argpath] - else: - collect_root = pkg_roots.get(argpath.parent, self) - col = collect_root._collectfile(argpath, handle_dupes=False) - if col: - node_cache1[argpath] = col - - matching = [] - work: List[ - Tuple[Sequence[Union[nodes.Item, nodes.Collector]], Sequence[str]] - ] = [(col, names)] - while work: - self.trace("matchnodes", col, names) - self.trace.root.indent += 1 - - matchnodes, matchnames = work.pop() - for node in matchnodes: - if not matchnames: - matching.append(node) - continue - if not isinstance(node, nodes.Collector): - continue - key = (type(node), node.nodeid) - if key in matchnodes_cache: - rep = matchnodes_cache[key] - else: - rep = collect_one_node(node) - matchnodes_cache[key] = rep - if rep.passed: - submatchnodes = [] - for r in rep.result: - # TODO: Remove parametrized workaround once collection structure contains - # parametrization. - if ( - r.name == matchnames[0] - or r.name.split("[")[0] == matchnames[0] - ): - submatchnodes.append(r) - if submatchnodes: - work.append((submatchnodes, matchnames[1:])) - else: - # Report collection failures here to avoid failing to run some test - # specified in the command line because the module could not be - # imported (#134). - node.ihook.pytest_collectreport(report=rep) - - self.trace("matchnodes finished -> ", len(matching), "nodes") - self.trace.root.indent -= 1 - - if not matching: - report_arg = "::".join((str(argpath), *names)) - self._notfound.append((report_arg, col)) + # Pop'd all of the parts, this is a match. + if not matchparts: + yield matchnode + any_matched_in_initial_part = True continue - yield from matching + # Should have been matched by now, discard. + if not isinstance(matchnode, nodes.Collector): + continue + + # Collect this level of matching. + # Collecting Session (self) is done directly to avoid endless + # recursion to this function. + subnodes: Sequence[Union[nodes.Collector, nodes.Item]] + if isinstance(matchnode, Session): + assert isinstance(matchparts[0], Path) + subnodes = matchnode._collect_path(matchparts[0], path_cache) + else: + # For backward compat, files given directly multiple + # times on the command line should not be deduplicated. + handle_dupes = not ( + len(matchparts) == 1 + and isinstance(matchparts[0], Path) + and matchparts[0].is_file() + ) + rep, duplicate = self._collect_one_node(matchnode, handle_dupes) + if not duplicate and not rep.passed: + # Report collection failures here to avoid failing to + # run some test specified in the command line because + # the module could not be imported (#134). + matchnode.ihook.pytest_collectreport(report=rep) + if not rep.passed: + continue + subnodes = rep.result + + # Prune this level. + any_matched_in_collector = False + for node in subnodes: + # Path part e.g. `/a/b/` in `/a/b/test_file.py::TestIt::test_it`. + if isinstance(matchparts[0], Path): + is_match = node.path == matchparts[0] + # Name part e.g. `TestIt` in `/a/b/test_file.py::TestIt::test_it`. + else: + # TODO: Remove parametrized workaround once collection structure contains + # parametrization. + is_match = ( + node.name == matchparts[0] + or node.name.split("[")[0] == matchparts[0] + ) + if is_match: + work.append((node, matchparts[1:])) + any_matched_in_collector = True + + if not any_matched_in_collector: + notfound_collectors.append(matchnode) + + if not any_matched_in_initial_part: + report_arg = "::".join((str(argpath), *names)) + self._notfound.append((report_arg, notfound_collectors)) self.trace.root.indent -= 1 @@ -863,11 +931,17 @@ class Session(nodes.FSCollector): yield node else: assert isinstance(node, nodes.Collector) - rep = collect_one_node(node) + keepduplicates = self.config.getoption("keepduplicates") + # For backward compat, dedup only applies to files. + handle_dupes = not (keepduplicates and isinstance(node, nodes.File)) + rep, duplicate = self._collect_one_node(node, handle_dupes) + if duplicate and not keepduplicates: + return if rep.passed: for subnode in rep.result: yield from self.genitems(subnode) - node.ihook.pytest_collectreport(report=rep) + if not duplicate: + node.ihook.pytest_collectreport(report=rep) def search_pypath(module_name: str) -> str: diff --git a/src/_pytest/mark/__init__.py b/src/_pytest/mark/__init__.py index de46b4c8a..3f97299ea 100644 --- a/src/_pytest/mark/__init__.py +++ b/src/_pytest/mark/__init__.py @@ -152,12 +152,19 @@ class KeywordMatcher: def from_item(cls, item: "Item") -> "KeywordMatcher": mapped_names = set() - # Add the names of the current item and any parent items. + # Add the names of the current item and any parent items, + # except the Session and root Directory's which are not + # interesting for matching. import pytest for node in item.listchain(): - if not isinstance(node, pytest.Session): - mapped_names.add(node.name) + if isinstance(node, pytest.Session): + continue + if isinstance(node, pytest.Directory) and isinstance( + node.parent, pytest.Session + ): + continue + mapped_names.add(node.name) # Add the names added as extra keywords to current or parent items. mapped_names.update(item.listextrakeywords()) diff --git a/src/_pytest/nodes.py b/src/_pytest/nodes.py index 183f3c9d9..29efd56f4 100644 --- a/src/_pytest/nodes.py +++ b/src/_pytest/nodes.py @@ -676,6 +676,24 @@ class File(FSCollector, abc.ABC): """ +class Directory(FSCollector, abc.ABC): + """Base class for collecting files from a directory. + + A basic directory collector does the following: goes over the files and + sub-directories in the directory and creates collectors for them by calling + the hooks :hook:`pytest_collect_directory` and :hook:`pytest_collect_file`, + after checking that they are not ignored using + :hook:`pytest_ignore_collect`. + + The default directory collectors are :class:`~pytest.Dir` and + :class:`~pytest.Package`. + + .. versionadded:: 8.0 + + :ref:`custom directory collectors`. + """ + + class Item(Node, abc.ABC): """Base class of all test invocation items. diff --git a/src/_pytest/pathlib.py b/src/_pytest/pathlib.py index e39b3dc8e..4cd635ed7 100644 --- a/src/_pytest/pathlib.py +++ b/src/_pytest/pathlib.py @@ -689,10 +689,14 @@ def resolve_package_path(path: Path) -> Optional[Path]: return result -def scandir(path: Union[str, "os.PathLike[str]"]) -> List["os.DirEntry[str]"]: +def scandir( + path: Union[str, "os.PathLike[str]"], + sort_key: Callable[["os.DirEntry[str]"], object] = lambda entry: entry.name, +) -> List["os.DirEntry[str]"]: """Scan a directory recursively, in breadth-first order. - The returned entries are sorted. + The returned entries are sorted according to the given key. + The default is to sort by name. """ entries = [] with os.scandir(path) as s: @@ -706,7 +710,7 @@ def scandir(path: Union[str, "os.PathLike[str]"]) -> List["os.DirEntry[str]"]: continue raise entries.append(entry) - entries.sort(key=lambda entry: entry.name) + entries.sort(key=sort_key) # type: ignore[arg-type] return entries diff --git a/src/_pytest/python.py b/src/_pytest/python.py index 3dd3026fb..09adb2b9c 100644 --- a/src/_pytest/python.py +++ b/src/_pytest/python.py @@ -76,8 +76,7 @@ from _pytest.pathlib import bestrelpath from _pytest.pathlib import fnmatch_ex from _pytest.pathlib import import_path from _pytest.pathlib import ImportPathMismatchError -from _pytest.pathlib import parts -from _pytest.pathlib import visit +from _pytest.pathlib import scandir from _pytest.scope import _ScopeName from _pytest.scope import Scope from _pytest.stash import StashKey @@ -204,6 +203,16 @@ def pytest_pyfunc_call(pyfuncitem: "Function") -> Optional[object]: return True +def pytest_collect_directory( + path: Path, parent: nodes.Collector +) -> Optional[nodes.Collector]: + pkginit = path / "__init__.py" + if pkginit.is_file(): + pkg: Package = Package.from_parent(parent, path=path) + return pkg + return None + + def pytest_collect_file(file_path: Path, parent: nodes.Collector) -> Optional["Module"]: if file_path.suffix == ".py": if not parent.session.isinitpath(file_path): @@ -659,9 +668,20 @@ class Module(nodes.File, PyCollector): self.obj.__pytest_setup_function = xunit_setup_function_fixture -class Package(nodes.FSCollector): +class Package(nodes.Directory): """Collector for files and directories in a Python packages -- directories - with an `__init__.py` file.""" + with an `__init__.py` file. + + .. note:: + + Directories without an `__init__.py` file are instead collected by + :class:`~pytest.Dir` by default. Both are :class:`~pytest.Directory` + collectors. + + .. versionchanged:: 8.0 + + Now inherits from :class:`~pytest.Directory`. + """ def __init__( self, @@ -674,10 +694,9 @@ class Package(nodes.FSCollector): path: Optional[Path] = None, ) -> None: # NOTE: Could be just the following, but kept as-is for compat. - # nodes.FSCollector.__init__(self, fspath, parent=parent) + # super().__init__(self, fspath, parent=parent) session = parent.session - nodes.FSCollector.__init__( - self, + super().__init__( fspath=fspath, path=path, parent=parent, @@ -685,7 +704,6 @@ class Package(nodes.FSCollector): session=session, nodeid=nodeid, ) - self.name = self.path.name def setup(self) -> None: init_mod = importtestmodule(self.path / "__init__.py", self.config) @@ -705,66 +723,35 @@ class Package(nodes.FSCollector): func = partial(_call_with_optional_argument, teardown_module, init_mod) self.addfinalizer(func) - def _recurse(self, direntry: "os.DirEntry[str]") -> bool: - if direntry.name == "__pycache__": - return False - fspath = Path(direntry.path) - ihook = self.session.gethookproxy(fspath.parent) - if ihook.pytest_ignore_collect(collection_path=fspath, config=self.config): - return False - return True - - def _collectfile( - self, fspath: Path, handle_dupes: bool = True - ) -> Sequence[nodes.Collector]: - assert ( - fspath.is_file() - ), "{!r} is not a file (isdir={!r}, exists={!r}, islink={!r})".format( - fspath, fspath.is_dir(), fspath.exists(), fspath.is_symlink() - ) - ihook = self.session.gethookproxy(fspath) - if not self.session.isinitpath(fspath): - if ihook.pytest_ignore_collect(collection_path=fspath, config=self.config): - return () - - if handle_dupes: - keepduplicates = self.config.getoption("keepduplicates") - if not keepduplicates: - duplicate_paths = self.config.pluginmanager._duplicatepaths - if fspath in duplicate_paths: - return () - else: - duplicate_paths.add(fspath) - - return ihook.pytest_collect_file(file_path=fspath, parent=self) # type: ignore[no-any-return] - def collect(self) -> Iterable[Union[nodes.Item, nodes.Collector]]: - # Always collect the __init__ first. - yield from self._collectfile(self.path / "__init__.py") + # Always collect __init__.py first. + def sort_key(entry: "os.DirEntry[str]") -> object: + return (entry.name != "__init__.py", entry.name) - pkg_prefixes: Set[Path] = set() - for direntry in visit(self.path, recurse=self._recurse): - path = Path(direntry.path) - - # Already handled above. - if direntry.is_file(): - if direntry.name == "__init__.py" and path.parent == self.path: + config = self.config + col: Optional[nodes.Collector] + cols: Sequence[nodes.Collector] + for direntry in scandir(self.path, sort_key): + if direntry.is_dir(): + if direntry.name == "__pycache__": continue + path = Path(direntry.path) + ihook = self.ihook + if not self.session.isinitpath(path, with_parents=True): + if ihook.pytest_ignore_collect(collection_path=path, config=config): + continue + col = ihook.pytest_collect_directory(path=path, parent=self) + if col is not None: + yield col - parts_ = parts(direntry.path) - if any( - str(pkg_prefix) in parts_ and pkg_prefix / "__init__.py" != path - for pkg_prefix in pkg_prefixes - ): - continue - - if direntry.is_file(): - yield from self._collectfile(path) - elif not direntry.is_dir(): - # Broken symlink or invalid/missing file. - continue - elif self._recurse(direntry) and path.joinpath("__init__.py").is_file(): - pkg_prefixes.add(path) + elif direntry.is_file(): + path = Path(direntry.path) + ihook = self.ihook + if not self.session.isinitpath(path): + if ihook.pytest_ignore_collect(collection_path=path, config=config): + continue + cols = ihook.pytest_collect_file(file_path=path, parent=self) + yield from cols def _call_with_optional_argument(func, arg) -> None: diff --git a/src/pytest/__init__.py b/src/pytest/__init__.py index 0aa496a2f..4e0c23ddb 100644 --- a/src/pytest/__init__.py +++ b/src/pytest/__init__.py @@ -30,6 +30,7 @@ from _pytest.freeze_support import freeze_includes from _pytest.legacypath import TempdirFactory from _pytest.legacypath import Testdir from _pytest.logging import LogCaptureFixture +from _pytest.main import Dir from _pytest.main import Session from _pytest.mark import Mark from _pytest.mark import MARK_GEN as mark @@ -38,6 +39,7 @@ from _pytest.mark import MarkGenerator from _pytest.mark import param from _pytest.monkeypatch import MonkeyPatch from _pytest.nodes import Collector +from _pytest.nodes import Directory from _pytest.nodes import File from _pytest.nodes import Item from _pytest.outcomes import exit @@ -98,6 +100,8 @@ __all__ = [ "Config", "console_main", "deprecated_call", + "Dir", + "Directory", "DoctestItem", "exit", "ExceptionInfo", diff --git a/testing/acceptance_test.py b/testing/acceptance_test.py index d597311ae..43390ab83 100644 --- a/testing/acceptance_test.py +++ b/testing/acceptance_test.py @@ -185,7 +185,8 @@ class TestGeneralUsage: assert result.ret == ExitCode.USAGE_ERROR result.stderr.fnmatch_lines( [ - f"ERROR: found no collectors for {p2}", + f"ERROR: not found: {p2}", + "(no match in any of *)", "", ] ) @@ -238,7 +239,7 @@ class TestGeneralUsage: pytester.copy_example("issue88_initial_file_multinodes") p = pytester.makepyfile("def test_hello(): pass") result = pytester.runpytest(p, "--collect-only") - result.stdout.fnmatch_lines(["*MyFile*test_issue88*", "*Module*test_issue88*"]) + result.stdout.fnmatch_lines(["*Module*test_issue88*", "*MyFile*test_issue88*"]) def test_issue93_initialnode_importing_capturing(self, pytester: Pytester) -> None: pytester.makeconftest( diff --git a/testing/example_scripts/customdirectory/conftest.py b/testing/example_scripts/customdirectory/conftest.py new file mode 100644 index 000000000..5357014d7 --- /dev/null +++ b/testing/example_scripts/customdirectory/conftest.py @@ -0,0 +1,22 @@ +# content of conftest.py +import json + +import pytest + + +class ManifestDirectory(pytest.Directory): + def collect(self): + manifest_path = self.path / "manifest.json" + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + ihook = self.ihook + for file in manifest["files"]: + yield from ihook.pytest_collect_file( + file_path=self.path / file, parent=self + ) + + +@pytest.hookimpl +def pytest_collect_directory(path, parent): + if path.joinpath("manifest.json").is_file(): + return ManifestDirectory.from_parent(parent=parent, path=path) + return None diff --git a/testing/example_scripts/customdirectory/pytest.ini b/testing/example_scripts/customdirectory/pytest.ini new file mode 100644 index 000000000..e69de29bb diff --git a/testing/example_scripts/customdirectory/tests/manifest.json b/testing/example_scripts/customdirectory/tests/manifest.json new file mode 100644 index 000000000..6ab6d0a52 --- /dev/null +++ b/testing/example_scripts/customdirectory/tests/manifest.json @@ -0,0 +1,6 @@ +{ + "files": [ + "test_first.py", + "test_second.py" + ] +} diff --git a/testing/example_scripts/customdirectory/tests/test_first.py b/testing/example_scripts/customdirectory/tests/test_first.py new file mode 100644 index 000000000..0a78de599 --- /dev/null +++ b/testing/example_scripts/customdirectory/tests/test_first.py @@ -0,0 +1,3 @@ +# content of test_first.py +def test_1(): + pass diff --git a/testing/example_scripts/customdirectory/tests/test_second.py b/testing/example_scripts/customdirectory/tests/test_second.py new file mode 100644 index 000000000..eed724a7d --- /dev/null +++ b/testing/example_scripts/customdirectory/tests/test_second.py @@ -0,0 +1,3 @@ +# content of test_second.py +def test_2(): + pass diff --git a/testing/example_scripts/customdirectory/tests/test_third.py b/testing/example_scripts/customdirectory/tests/test_third.py new file mode 100644 index 000000000..61cf59dc1 --- /dev/null +++ b/testing/example_scripts/customdirectory/tests/test_third.py @@ -0,0 +1,3 @@ +# content of test_third.py +def test_3(): + pass diff --git a/testing/python/collect.py b/testing/python/collect.py index 309d7e680..da11dd34a 100644 --- a/testing/python/collect.py +++ b/testing/python/collect.py @@ -1514,3 +1514,108 @@ def test_package_ordering(pytester: Pytester) -> None: # Execute from . result = pytester.runpytest("-v", "-s") result.assert_outcomes(passed=3) + + +def test_collection_hierarchy(pytester: Pytester) -> None: + """A general test checking that a filesystem hierarchy is collected as + expected in various scenarios. + + top/ + ├── aaa + │ ├── pkg + │ │ ├── __init__.py + │ │ └── test_pkg.py + │ └── test_aaa.py + ├── test_a.py + ├── test_b + │ ├── __init__.py + │ └── test_b.py + ├── test_c.py + └── zzz + ├── dir + │ └── test_dir.py + ├── __init__.py + └── test_zzz.py + """ + pytester.makepyfile( + **{ + "top/aaa/test_aaa.py": "def test_it(): pass", + "top/aaa/pkg/__init__.py": "", + "top/aaa/pkg/test_pkg.py": "def test_it(): pass", + "top/test_a.py": "def test_it(): pass", + "top/test_b/__init__.py": "", + "top/test_b/test_b.py": "def test_it(): pass", + "top/test_c.py": "def test_it(): pass", + "top/zzz/__init__.py": "", + "top/zzz/test_zzz.py": "def test_it(): pass", + "top/zzz/dir/test_dir.py": "def test_it(): pass", + } + ) + + full = [ + "", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + ] + result = pytester.runpytest("--collect-only") + result.stdout.fnmatch_lines(full, consecutive=True) + result = pytester.runpytest("top", "--collect-only") + result.stdout.fnmatch_lines(full, consecutive=True) + result = pytester.runpytest("top", "top", "--collect-only") + result.stdout.fnmatch_lines(full, consecutive=True) + + result = pytester.runpytest( + "top/aaa", "top/aaa/pkg", "--collect-only", "--keep-duplicates" + ) + result.stdout.fnmatch_lines( + [ + "", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + ], + consecutive=True, + ) + + result = pytester.runpytest( + "top/aaa/pkg", "top/aaa", "--collect-only", "--keep-duplicates" + ) + result.stdout.fnmatch_lines( + [ + "", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + ], + consecutive=True, + ) diff --git a/testing/python/metafunc.py b/testing/python/metafunc.py index e93363a78..9768c82ff 100644 --- a/testing/python/metafunc.py +++ b/testing/python/metafunc.py @@ -1005,16 +1005,16 @@ class TestMetafunc: result = pytester.runpytest("--collect-only") result.stdout.re_match_lines( [ - r" ", - r" ", - r" ", - r" ", - r" ", - r" ", - r" ", - r" ", - r" ", - r" ", + r" ", + r" ", + r" ", + r" ", + r" ", + r" ", + r" ", + r" ", + r" ", + r" ", ] ) diff --git a/testing/test_assertion.py b/testing/test_assertion.py index 4d751f8db..c08c1cd82 100644 --- a/testing/test_assertion.py +++ b/testing/test_assertion.py @@ -1574,12 +1574,12 @@ def test_assertrepr_loaded_per_dir(pytester: Pytester) -> None: result = pytester.runpytest() result.stdout.fnmatch_lines( [ - "*def test_base():*", - "*E*assert 1 == 2*", "*def test_a():*", "*E*assert summary a*", "*def test_b():*", "*E*assert summary b*", + "*def test_base():*", + "*E*assert 1 == 2*", ] ) @@ -1744,9 +1744,9 @@ def test_recursion_source_decode(pytester: Pytester) -> None: ) result = pytester.runpytest("--collect-only") result.stdout.fnmatch_lines( - """ - - """ + [ + " ", + ] ) diff --git a/testing/test_cacheprovider.py b/testing/test_cacheprovider.py index e2e195ca7..21c1957cf 100644 --- a/testing/test_cacheprovider.py +++ b/testing/test_cacheprovider.py @@ -422,7 +422,7 @@ class TestLastFailed: result = pytester.runpytest() result.stdout.fnmatch_lines(["*1 failed in*"]) - @pytest.mark.parametrize("parent", ("session", "package")) + @pytest.mark.parametrize("parent", ("directory", "package")) def test_terminal_report_lastfailed(self, pytester: Pytester, parent: str) -> None: if parent == "package": pytester.makepyfile( @@ -936,8 +936,10 @@ class TestLastFailed: "collected 1 item", "run-last-failure: rerun previous 1 failure (skipped 1 file)", "", - "", - " ", + "", + " ", + " ", + " ", ] ) @@ -966,8 +968,10 @@ class TestLastFailed: "*collected 1 item", "run-last-failure: 1 known failures not in selected tests", "", - "", - " ", + "", + " ", + " ", + " ", ], consecutive=True, ) @@ -981,8 +985,10 @@ class TestLastFailed: "collected 2 items / 1 deselected / 1 selected", "run-last-failure: rerun previous 1 failure", "", - "", - " ", + "", + " ", + " ", + " ", "*= 1/2 tests collected (1 deselected) in *", ], ) @@ -1011,10 +1017,12 @@ class TestLastFailed: "collected 3 items / 1 deselected / 2 selected", "run-last-failure: rerun previous 2 failures", "", - "", - " ", - " ", - " ", + "", + " ", + " ", + " ", + " ", + " ", "", "*= 2/3 tests collected (1 deselected) in *", ], @@ -1048,8 +1056,10 @@ class TestLastFailed: "collected 1 item", "run-last-failure: 1 known failures not in selected tests", "", - "", - " ", + "", + " ", + " ", + " ", "", "*= 1 test collected in*", ], diff --git a/testing/test_collection.py b/testing/test_collection.py index b2492f7f2..deed4bda5 100644 --- a/testing/test_collection.py +++ b/testing/test_collection.py @@ -490,7 +490,7 @@ class TestSession: # assert root2 == rcol, rootid colitems = rcol.perform_collect([rcol.nodeid], genitems=False) assert len(colitems) == 1 - assert colitems[0].path == p + assert colitems[0].path == topdir def get_reported_items(self, hookrec: HookRecorder) -> List[Item]: """Return pytest.Item instances reported by the pytest_collectreport hook""" @@ -568,12 +568,12 @@ class TestSession: hookrec.assert_contains( [ ("pytest_collectstart", "collector.path == collector.session.path"), + ("pytest_collectstart", "collector.__class__.__name__ == 'Module'"), + ("pytest_pycollect_makeitem", "name == 'test_func'"), ( "pytest_collectstart", "collector.__class__.__name__ == 'SpecialFile'", ), - ("pytest_collectstart", "collector.__class__.__name__ == 'Module'"), - ("pytest_pycollect_makeitem", "name == 'test_func'"), ("pytest_collectreport", "report.nodeid.startswith(p.name)"), ] ) @@ -657,7 +657,8 @@ class Test_getinitialnodes: assert isinstance(col, pytest.Module) assert col.name == "x.py" assert col.parent is not None - assert col.parent.parent is None + assert col.parent.parent is not None + assert col.parent.parent.parent is None for parent in col.listchain(): assert parent.config is config @@ -937,6 +938,46 @@ class TestNodeKeywords: assert "baz" not in mod.keywords +class TestCollectDirectoryHook: + def test_custom_directory_example(self, pytester: Pytester) -> None: + """Verify the example from the customdirectory.rst doc.""" + pytester.copy_example("customdirectory") + + reprec = pytester.inline_run() + + reprec.assertoutcome(passed=2, failed=0) + calls = reprec.getcalls("pytest_collect_directory") + assert len(calls) == 2 + assert calls[0].path == pytester.path + assert isinstance(calls[0].parent, pytest.Session) + assert calls[1].path == pytester.path / "tests" + assert isinstance(calls[1].parent, pytest.Dir) + + def test_directory_ignored_if_none(self, pytester: Pytester) -> None: + """If the (entire) hook returns None, it's OK, the directory is ignored.""" + pytester.makeconftest( + """ + import pytest + + @pytest.hookimpl(wrapper=True) + def pytest_collect_directory(): + yield + return None + """, + ) + pytester.makepyfile( + **{ + "tests/test_it.py": """ + import pytest + + def test_it(): pass + """, + }, + ) + reprec = pytester.inline_run() + reprec.assertoutcome(passed=0, failed=0) + + COLLECTION_ERROR_PY_FILES = dict( test_01_failure=""" def test_1(): @@ -1098,22 +1139,24 @@ def test_collect_init_tests(pytester: Pytester) -> None: result.stdout.fnmatch_lines( [ "collected 2 items", - "", - " ", - " ", - " ", - " ", + "", + " ", + " ", + " ", + " ", + " ", ] ) result = pytester.runpytest("./tests", "--collect-only") result.stdout.fnmatch_lines( [ "collected 2 items", - "", - " ", - " ", - " ", - " ", + "", + " ", + " ", + " ", + " ", + " ", ] ) # Ignores duplicates with "." and pkginit (#4310). @@ -1121,11 +1164,12 @@ def test_collect_init_tests(pytester: Pytester) -> None: result.stdout.fnmatch_lines( [ "collected 2 items", - "", - " ", - " ", - " ", - " ", + "", + " ", + " ", + " ", + " ", + " ", ] ) # Same as before, but different order. @@ -1133,21 +1177,32 @@ def test_collect_init_tests(pytester: Pytester) -> None: result.stdout.fnmatch_lines( [ "collected 2 items", - "", - " ", - " ", - " ", - " ", + "", + " ", + " ", + " ", + " ", + " ", ] ) result = pytester.runpytest("./tests/test_foo.py", "--collect-only") result.stdout.fnmatch_lines( - ["", " ", " "] + [ + "", + " ", + " ", + " ", + ] ) result.stdout.no_fnmatch_line("*test_init*") result = pytester.runpytest("./tests/__init__.py", "--collect-only") result.stdout.fnmatch_lines( - ["", " ", " "] + [ + "", + " ", + " ", + " ", + ] ) result.stdout.no_fnmatch_line("*test_foo*") diff --git a/testing/test_config.py b/testing/test_config.py index 900cccee8..18022977c 100644 --- a/testing/test_config.py +++ b/testing/test_config.py @@ -1966,7 +1966,8 @@ def test_config_blocked_default_plugins(pytester: Pytester, plugin: str) -> None assert result.ret == ExitCode.USAGE_ERROR result.stderr.fnmatch_lines( [ - "ERROR: found no collectors for */test_config_blocked_default_plugins.py", + "ERROR: not found: */test_config_blocked_default_plugins.py", + "(no match in any of **", ] ) return diff --git a/testing/test_mark.py b/testing/test_mark.py index 7415b393e..609f73d68 100644 --- a/testing/test_mark.py +++ b/testing/test_mark.py @@ -871,17 +871,30 @@ class TestKeywordSelection: deselected_tests = dlist[0].items assert len(deselected_tests) == 1 - def test_no_match_directories_outside_the_suite(self, pytester: Pytester) -> None: + def test_no_match_directories_outside_the_suite( + self, + pytester: Pytester, + monkeypatch: pytest.MonkeyPatch, + ) -> None: """`-k` should not match against directories containing the test suite (#7040).""" - test_contents = """ - def test_aaa(): pass - def test_ddd(): pass - """ - pytester.makepyfile( - **{"ddd/tests/__init__.py": "", "ddd/tests/test_foo.py": test_contents} + pytester.makefile( + **{ + "suite/pytest": """[pytest]""", + }, + ext=".ini", ) + pytester.makepyfile( + **{ + "suite/ddd/tests/__init__.py": "", + "suite/ddd/tests/test_foo.py": """ + def test_aaa(): pass + def test_ddd(): pass + """, + } + ) + monkeypatch.chdir(pytester.path / "suite") - def get_collected_names(*args): + def get_collected_names(*args: str) -> List[str]: _, rec = pytester.inline_genitems(*args) calls = rec.getcalls("pytest_collection_finish") assert len(calls) == 1 @@ -893,12 +906,6 @@ class TestKeywordSelection: # do not collect anything based on names outside the collection tree assert get_collected_names("-k", pytester._name) == [] - # "-k ddd" should only collect "test_ddd", but not - # 'test_aaa' just because one of its parent directories is named "ddd"; - # this was matched previously because Package.name would contain the full path - # to the package - assert get_collected_names("-k", "ddd") == ["test_ddd"] - class TestMarkDecorator: @pytest.mark.parametrize( diff --git a/testing/test_reports.py b/testing/test_reports.py index 387d2e807..627ea1ed2 100644 --- a/testing/test_reports.py +++ b/testing/test_reports.py @@ -304,9 +304,9 @@ class TestReportSerialization: report = reports[1] else: assert report_class is CollectReport - # two collection reports: session and test file + # three collection reports: session, test file, directory reports = reprec.getreports("pytest_collectreport") - assert len(reports) == 2 + assert len(reports) == 3 report = reports[1] def check_longrepr(longrepr: ExceptionChainRepr) -> None: @@ -471,7 +471,7 @@ class TestHooks: ) reprec = pytester.inline_run() reports = reprec.getreports("pytest_collectreport") - assert len(reports) == 2 + assert len(reports) == 3 for rep in reports: data = pytestconfig.hook.pytest_report_to_serializable( config=pytestconfig, report=rep diff --git a/testing/test_runner.py b/testing/test_runner.py index cab631ee1..c8b646857 100644 --- a/testing/test_runner.py +++ b/testing/test_runner.py @@ -1006,7 +1006,7 @@ class TestReportContents: ) rec = pytester.inline_run() calls = rec.getcalls("pytest_collectreport") - _, call = calls + _, call, _ = calls assert isinstance(call.report.longrepr, tuple) assert "Skipped" in call.report.longreprtext diff --git a/testing/test_session.py b/testing/test_session.py index 48dc08e8c..136e85eb6 100644 --- a/testing/test_session.py +++ b/testing/test_session.py @@ -172,8 +172,9 @@ class SessionTests: except pytest.skip.Exception: # pragma: no cover pytest.fail("wrong skipped caught") reports = reprec.getreports("pytest_collectreport") - assert len(reports) == 1 - assert reports[0].skipped + # Session, Dir + assert len(reports) == 2 + assert reports[1].skipped class TestNewSession(SessionTests): @@ -357,9 +358,10 @@ def test_collection_args_do_not_duplicate_modules(pytester: Pytester) -> None: ) result.stdout.fnmatch_lines( [ - "", - " ", - " ", + " ", + " ", + " ", + " ", ], consecutive=True, ) @@ -373,11 +375,12 @@ def test_collection_args_do_not_duplicate_modules(pytester: Pytester) -> None: ) result.stdout.fnmatch_lines( [ - "", - " ", - " ", - " ", - " ", + " ", + " ", + " ", + " ", + " ", + " ", ], consecutive=True, ) diff --git a/testing/test_terminal.py b/testing/test_terminal.py index 264ab96d8..12c80f9b8 100644 --- a/testing/test_terminal.py +++ b/testing/test_terminal.py @@ -451,7 +451,11 @@ class TestCollectonly: ) result = pytester.runpytest("--collect-only") result.stdout.fnmatch_lines( - ["", " "] + [ + "", + " ", + " ", + ] ) def test_collectonly_skipped_module(self, pytester: Pytester) -> None: @@ -480,14 +484,15 @@ class TestCollectonly: result = pytester.runpytest("--collect-only", "--verbose") result.stdout.fnmatch_lines( [ - "", - " ", - "", - " ", - " This test has a description.", - " ", - " more1.", - " more2.", + "", + " ", + " ", + " ", + " ", + " This test has a description.", + " ", + " more1.", + " more2.", ], consecutive=True, ) @@ -2001,9 +2006,9 @@ class TestClassicOutputStyle: result = pytester.runpytest("-o", "console_output_style=classic") result.stdout.fnmatch_lines( [ + f"sub{os.sep}test_three.py .F.", "test_one.py .", "test_two.py F", - f"sub{os.sep}test_three.py .F.", "*2 failed, 3 passed in*", ] ) @@ -2012,18 +2017,18 @@ class TestClassicOutputStyle: result = pytester.runpytest("-o", "console_output_style=classic", "-v") result.stdout.fnmatch_lines( [ - "test_one.py::test_one PASSED", - "test_two.py::test_two FAILED", f"sub{os.sep}test_three.py::test_three_1 PASSED", f"sub{os.sep}test_three.py::test_three_2 FAILED", f"sub{os.sep}test_three.py::test_three_3 PASSED", + "test_one.py::test_one PASSED", + "test_two.py::test_two FAILED", "*2 failed, 3 passed in*", ] ) def test_quiet(self, pytester: Pytester, test_files) -> None: result = pytester.runpytest("-o", "console_output_style=classic", "-q") - result.stdout.fnmatch_lines([".F.F.", "*2 failed, 3 passed in*"]) + result.stdout.fnmatch_lines([".F..F", "*2 failed, 3 passed in*"]) class TestProgressOutputStyle: From e1c66ab0ad8eda13e5552dfc939e07d7290ecd39 Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Tue, 5 Dec 2023 23:07:06 +0200 Subject: [PATCH 3/3] 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. --- src/_pytest/main.py | 40 +--------------------------------------- src/_pytest/python.py | 3 +-- src/_pytest/runner.py | 19 ++++++++++++++++++- 3 files changed, 20 insertions(+), 42 deletions(-) diff --git a/src/_pytest/main.py b/src/_pytest/main.py index e4ca05aac..3672df05a 100644 --- a/src/_pytest/main.py +++ b/src/_pytest/main.py @@ -12,7 +12,6 @@ from typing import Callable from typing import Dict from typing import final from typing import FrozenSet -from typing import Generator from typing import Iterable from typing import Iterator from typing import List @@ -502,11 +501,11 @@ class Dir(nodes.Directory): config = self.config col: Optional[nodes.Collector] cols: Sequence[nodes.Collector] + ihook = self.ihook for direntry in scandir(self.path): if direntry.is_dir(): if direntry.name == "__pycache__": continue - ihook = self.ihook path = Path(direntry.path) if not self.session.isinitpath(path, with_parents=True): if ihook.pytest_ignore_collect(collection_path=path, config=config): @@ -516,7 +515,6 @@ class Dir(nodes.Directory): yield col elif direntry.is_file(): - ihook = self.ihook path = Path(direntry.path) if not self.session.isinitpath(path): 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._notfound: List[Tuple[str, Sequence[nodes.Collector]]] = [] self._initial_parts: List[Tuple[Path, List[str]]] = [] - self._in_genitems = False self._collection_cache: Dict[nodes.Collector, CollectReport] = {} self.items: List[nodes.Item] = [] @@ -612,29 +609,6 @@ class Session(nodes.Collector): 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( self, path: Union[str, "os.PathLike[str]"], @@ -665,15 +639,6 @@ class Session(nodes.Collector): pm = self.config.pluginmanager # Check if we have the common case of running # 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) remove_mods = pm._conftest_plugins.difference(my_conftestmodules) proxy: pluggy.HookRelay @@ -754,7 +719,6 @@ class Session(nodes.Collector): self._notfound = [] self._initial_parts = [] - self._in_genitems = False self._collection_cache = {} self.items = [] items: Sequence[Union[nodes.Item, nodes.Collector]] = self.items @@ -789,7 +753,6 @@ class Session(nodes.Collector): raise UsageError(*errors) - self._in_genitems = True if not genitems: items = rep.result else: @@ -804,7 +767,6 @@ class Session(nodes.Collector): finally: self._notfound = [] self._initial_parts = [] - self._in_genitems = False self._collection_cache = {} hook.pytest_collection_finish(session=self) diff --git a/src/_pytest/python.py b/src/_pytest/python.py index 09adb2b9c..e0f7a447a 100644 --- a/src/_pytest/python.py +++ b/src/_pytest/python.py @@ -731,12 +731,12 @@ class Package(nodes.Directory): config = self.config col: Optional[nodes.Collector] cols: Sequence[nodes.Collector] + ihook = self.ihook for direntry in scandir(self.path, sort_key): if direntry.is_dir(): if direntry.name == "__pycache__": continue path = Path(direntry.path) - ihook = self.ihook if not self.session.isinitpath(path, with_parents=True): if ihook.pytest_ignore_collect(collection_path=path, config=config): continue @@ -746,7 +746,6 @@ class Package(nodes.Directory): elif direntry.is_file(): path = Path(direntry.path) - ihook = self.ihook if not self.session.isinitpath(path): if ihook.pytest_ignore_collect(collection_path=path, config=config): continue diff --git a/src/_pytest/runner.py b/src/_pytest/runner.py index 1b39f93cf..dcfc6b7d0 100644 --- a/src/_pytest/runner.py +++ b/src/_pytest/runner.py @@ -28,6 +28,7 @@ from _pytest._code.code import TerminalRepr from _pytest.config.argparsing import Parser from _pytest.deprecated import check_ispytest from _pytest.nodes import Collector +from _pytest.nodes import Directory from _pytest.nodes import Item from _pytest.nodes import Node 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: - 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 if not call.excinfo: outcome: Literal["passed", "skipped", "failed"] = "passed"