Skip to content

Commit

Permalink
Rework Session and Package collection
Browse files Browse the repository at this point in the history
  • Loading branch information
bluetech committed Nov 27, 2023
1 parent 0c32c88 commit 0e62302
Show file tree
Hide file tree
Showing 33 changed files with 865 additions and 333 deletions.
87 changes: 87 additions & 0 deletions changelog/7777.breaking.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
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 (this 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>
83 changes: 83 additions & 0 deletions doc/en/deprecations.rst
Original file line number Diff line number Diff line change
Expand Up @@ -495,6 +495,89 @@ 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 (this 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>



:class:`pytest.Package` is no longer a :class:`pytest.Module` or :class:`pytest.File`
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

Expand Down
2 changes: 1 addition & 1 deletion doc/en/example/conftest.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
collect_ignore = ["nonpython"]
collect_ignore = ["nonpython", "customdirectory"]
78 changes: 78 additions & 0 deletions doc/en/example/customdirectory.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
.. _`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.
This plugin 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 ========================
22 changes: 22 additions & 0 deletions doc/en/example/customdirectory/conftest.py
Original file line number Diff line number Diff line change
@@ -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
Empty file.
6 changes: 6 additions & 0 deletions doc/en/example/customdirectory/tests/manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"files": [
"test_first.py",
"test_second.py"
]
}
3 changes: 3 additions & 0 deletions doc/en/example/customdirectory/tests/test_first.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# content of test_first.py
def test_1():
pass
3 changes: 3 additions & 0 deletions doc/en/example/customdirectory/tests/test_second.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# content of test_second.py
def test_2():
pass
3 changes: 3 additions & 0 deletions doc/en/example/customdirectory/tests/test_third.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# content of test_third.py
def test_3():
pass
1 change: 1 addition & 0 deletions doc/en/example/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,4 @@ The following examples aim at various use cases you might encounter.
special
pythoncollection
nonpython
customdirectory
14 changes: 14 additions & 0 deletions doc/en/reference/reference.rst
Original file line number Diff line number Diff line change
Expand Up @@ -662,6 +662,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
Expand Down Expand Up @@ -900,6 +902,18 @@ Config
.. autoclass:: pytest.Config()
:members:

Dir
~~~

.. autoclass:: pytest.Dir()
:members:

Directory
~~~~~~~~~

.. autoclass:: pytest.Directory()
:members:

ExceptionInfo
~~~~~~~~~~~~~

Expand Down
4 changes: 2 additions & 2 deletions src/_pytest/cacheprovider.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,8 @@
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 = """\
Expand Down Expand Up @@ -222,7 +222,7 @@ def pytest_make_collect_report(
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

Expand Down
2 changes: 0 additions & 2 deletions src/_pytest/config/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -415,8 +415,6 @@ def __init__(self) -> None:
# 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
Expand Down
15 changes: 15 additions & 0 deletions src/_pytest/hookspec.py
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,21 @@ 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
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.
"""


def pytest_collect_file(
file_path: Path, path: "LEGACY_PATH", parent: "Collector"
) -> "Optional[Collector]":
Expand Down
Loading

0 comments on commit 0e62302

Please sign in to comment.