Rework Session and Package collection

Fix #7777.
This commit is contained in:
Ran Benita 2023-06-02 16:03:39 +03:00
parent f411c8d6d7
commit 385796ba49
39 changed files with 985 additions and 335 deletions

View File

@ -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 <custom directory collectors>`.
:class:`pytest.Session` now only collects the initial arguments, without recursing into directories.
This work is now done by the :func:`recursive expansion process <pytest.Collector.collect>` of directory collector nodes.
:attr:`session.name <pytest.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 <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::
<Session>
<Dir myroot>
<Dir top>
<Dir aaa>
<Module test_aaa.py>
<Function test_it>
<Module test_a.py>
<Function test_it>
<Package test_b>
<Module test_b.py>
<Function test_it>
<Module test_c.py>
<Function test_it>
<Package zzz>
<Module test_zzz.py>
<Function test_it>
Previously, it was::
<Session>
<Module top/test_a.py>
<Function test_it>
<Module top/test_c.py>
<Function test_it>
<Module top/aaa/test_aaa.py>
<Function test_it>
<Package test_b>
<Module test_b.py>
<Function test_it>
<Package zzz>
<Module test_zzz.py>
<Function test_it>
Code/plugins which rely on a specific shape of the collection tree might need to update.

View File

@ -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 <pytest.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 <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::
<Session>
<Dir myroot>
<Dir top>
<Dir aaa>
<Module test_aaa.py>
<Function test_it>
<Module test_a.py>
<Function test_it>
<Package test_b>
<Module test_b.py>
<Function test_it>
<Module test_c.py>
<Function test_it>
<Package zzz>
<Module test_zzz.py>
<Function test_it>
Previously, it was::
<Session>
<Module top/test_a.py>
<Function test_it>
<Module top/test_c.py>
<Function test_it>
<Module top/aaa/test_aaa.py>
<Function test_it>
<Package test_b>
<Module test_b.py>
<Function test_it>
<Package zzz>
<Module test_zzz.py>
<Function test_it>
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`
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

View File

@ -1 +1 @@
collect_ignore = ["nonpython"]
collect_ignore = ["nonpython", "customdirectory"]

View File

@ -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
<Dir customdirectory>
<ManifestDirectory tests>
<Module test_first.py>
<Function test_1>
<Module test_second.py>
<Function test_2>
======================== 2 tests collected in 0.12s ========================

View File

@ -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

View File

@ -0,0 +1,6 @@
{
"files": [
"test_first.py",
"test_second.py"
]
}

View File

@ -0,0 +1,3 @@
# content of test_first.py
def test_1():
pass

View File

@ -0,0 +1,3 @@
# content of test_second.py
def test_2():
pass

View File

@ -0,0 +1,3 @@
# content of test_third.py
def test_3():
pass

View File

@ -32,3 +32,4 @@ The following examples aim at various use cases you might encounter.
special
pythoncollection
nonpython
customdirectory

View File

@ -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
~~~~~~~~~~~~~

View File

@ -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

View File

@ -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

View File

@ -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.

View File

@ -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:

View File

@ -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())

View File

@ -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.

View File

@ -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

View File

@ -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:

View File

@ -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",

View File

@ -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(

View File

@ -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

View File

@ -0,0 +1,6 @@
{
"files": [
"test_first.py",
"test_second.py"
]
}

View File

@ -0,0 +1,3 @@
# content of test_first.py
def test_1():
pass

View File

@ -0,0 +1,3 @@
# content of test_second.py
def test_2():
pass

View File

@ -0,0 +1,3 @@
# content of test_third.py
def test_3():
pass

View File

@ -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 = [
"<Dir test_collection_hierarchy*>",
" <Dir top>",
" <Dir aaa>",
" <Package pkg>",
" <Module test_pkg.py>",
" <Function test_it>",
" <Module test_aaa.py>",
" <Function test_it>",
" <Module test_a.py>",
" <Function test_it>",
" <Package test_b>",
" <Module test_b.py>",
" <Function test_it>",
" <Module test_c.py>",
" <Function test_it>",
" <Package zzz>",
" <Dir dir>",
" <Module test_dir.py>",
" <Function test_it>",
" <Module test_zzz.py>",
" <Function test_it>",
]
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(
[
"<Dir test_collection_hierarchy*>",
" <Dir top>",
" <Dir aaa>",
" <Package pkg>",
" <Module test_pkg.py>",
" <Function test_it>",
" <Module test_aaa.py>",
" <Function test_it>",
" <Package pkg>",
" <Module test_pkg.py>",
" <Function test_it>",
],
consecutive=True,
)
result = pytester.runpytest(
"top/aaa/pkg", "top/aaa", "--collect-only", "--keep-duplicates"
)
result.stdout.fnmatch_lines(
[
"<Dir test_collection_hierarchy*>",
" <Dir top>",
" <Dir aaa>",
" <Package pkg>",
" <Module test_pkg.py>",
" <Function test_it>",
" <Function test_it>",
" <Module test_aaa.py>",
" <Function test_it>",
],
consecutive=True,
)

View File

@ -1005,16 +1005,16 @@ class TestMetafunc:
result = pytester.runpytest("--collect-only")
result.stdout.re_match_lines(
[
r" <Function test1\[0-3\]>",
r" <Function test1\[0-4\]>",
r" <Function test3\[0\]>",
r" <Function test1\[1-3\]>",
r" <Function test1\[1-4\]>",
r" <Function test3\[1\]>",
r" <Function test1\[2-3\]>",
r" <Function test1\[2-4\]>",
r" <Function test3\[2\]>",
r" <Function test2>",
r" <Function test1\[0-3\]>",
r" <Function test1\[0-4\]>",
r" <Function test3\[0\]>",
r" <Function test1\[1-3\]>",
r" <Function test1\[1-4\]>",
r" <Function test3\[1\]>",
r" <Function test1\[2-3\]>",
r" <Function test1\[2-4\]>",
r" <Function test3\[2\]>",
r" <Function test2>",
]
)

View File

@ -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(
"""
<Module*>
"""
[
" <Module*>",
]
)

View File

@ -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)",
"",
"<Module pkg1/test_1.py>",
" <Function test_renamed>",
"<Dir *>",
" <Dir pkg1>",
" <Module test_1.py>",
" <Function test_renamed>",
]
)
@ -966,8 +968,10 @@ class TestLastFailed:
"*collected 1 item",
"run-last-failure: 1 known failures not in selected tests",
"",
"<Module pkg1/test_1.py>",
" <Function test_pass>",
"<Dir *>",
" <Dir pkg1>",
" <Module test_1.py>",
" <Function test_pass>",
],
consecutive=True,
)
@ -981,8 +985,10 @@ class TestLastFailed:
"collected 2 items / 1 deselected / 1 selected",
"run-last-failure: rerun previous 1 failure",
"",
"<Module pkg1/test_1.py>",
" <Function test_fail>",
"<Dir *>",
" <Dir pkg1>",
" <Module test_1.py>",
" <Function test_fail>",
"*= 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",
"",
"<Module pkg1/test_1.py>",
" <Class TestFoo>",
" <Function test_fail>",
" <Function test_other>",
"<Dir *>",
" <Dir pkg1>",
" <Module test_1.py>",
" <Class TestFoo>",
" <Function test_fail>",
" <Function test_other>",
"",
"*= 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",
"",
"<Module pkg1/test_1.py>",
" <Function test_pass>",
"<Dir *>",
" <Dir pkg1>",
" <Module test_1.py>",
" <Function test_pass>",
"",
"*= 1 test collected in*",
],

View File

@ -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",
"<Package tests>",
" <Module __init__.py>",
" <Function test_init>",
" <Module test_foo.py>",
" <Function test_foo>",
"<Dir *>",
" <Package tests>",
" <Module __init__.py>",
" <Function test_init>",
" <Module test_foo.py>",
" <Function test_foo>",
]
)
result = pytester.runpytest("./tests", "--collect-only")
result.stdout.fnmatch_lines(
[
"collected 2 items",
"<Package tests>",
" <Module __init__.py>",
" <Function test_init>",
" <Module test_foo.py>",
" <Function test_foo>",
"<Dir *>",
" <Package tests>",
" <Module __init__.py>",
" <Function test_init>",
" <Module test_foo.py>",
" <Function test_foo>",
]
)
# 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",
"<Package tests>",
" <Module __init__.py>",
" <Function test_init>",
" <Module test_foo.py>",
" <Function test_foo>",
"<Dir *>",
" <Package tests>",
" <Module __init__.py>",
" <Function test_init>",
" <Module test_foo.py>",
" <Function test_foo>",
]
)
# 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",
"<Package tests>",
" <Module __init__.py>",
" <Function test_init>",
" <Module test_foo.py>",
" <Function test_foo>",
"<Dir *>",
" <Package tests>",
" <Module __init__.py>",
" <Function test_init>",
" <Module test_foo.py>",
" <Function test_foo>",
]
)
result = pytester.runpytest("./tests/test_foo.py", "--collect-only")
result.stdout.fnmatch_lines(
["<Package tests>", " <Module test_foo.py>", " <Function test_foo>"]
[
"<Dir *>",
" <Package tests>",
" <Module test_foo.py>",
" <Function test_foo>",
]
)
result.stdout.no_fnmatch_line("*test_init*")
result = pytester.runpytest("./tests/__init__.py", "--collect-only")
result.stdout.fnmatch_lines(
["<Package tests>", " <Module __init__.py>", " <Function test_init>"]
[
"<Dir *>",
" <Package tests>",
" <Module __init__.py>",
" <Function test_init>",
]
)
result.stdout.no_fnmatch_line("*test_foo*")

View File

@ -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 *<Dir *>*",
]
)
return

View File

@ -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(

View File

@ -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

View File

@ -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

View File

@ -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(
[
"<Module d/test_it.py>",
" <Function test_1>",
" <Function test_2>",
" <Dir d>",
" <Module test_it.py>",
" <Function test_1>",
" <Function test_2>",
],
consecutive=True,
)
@ -373,11 +375,12 @@ def test_collection_args_do_not_duplicate_modules(pytester: Pytester) -> None:
)
result.stdout.fnmatch_lines(
[
"<Module d/test_it.py>",
" <Function test_1>",
" <Function test_2>",
" <Function test_1>",
" <Function test_2>",
" <Dir d>",
" <Module test_it.py>",
" <Function test_1>",
" <Function test_2>",
" <Function test_1>",
" <Function test_2>",
],
consecutive=True,
)

View File

@ -451,7 +451,11 @@ class TestCollectonly:
)
result = pytester.runpytest("--collect-only")
result.stdout.fnmatch_lines(
["<Module test_collectonly_basic.py>", " <Function test_func>"]
[
"<Dir test_collectonly_basic0>",
" <Module test_collectonly_basic.py>",
" <Function test_func>",
]
)
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(
[
"<YamlFile test1.yaml>",
" <YamlItem test1.yaml>",
"<Module test_collectonly_displays_test_description.py>",
" <Function test_with_description>",
" This test has a description.",
" ",
" more1.",
" more2.",
"<Dir test_collectonly_displays_test_description0>",
" <YamlFile test1.yaml>",
" <YamlItem test1.yaml>",
" <Module test_collectonly_displays_test_description.py>",
" <Function test_with_description>",
" 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: