Skip to content

Commit acd445a

Browse files
authored
Merge pull request #11646 from bluetech/pkg-collect
Rework Session and Package collection
2 parents c7ee559 + e1c66ab commit acd445a

40 files changed

+997
-345
lines changed

changelog/7777.breaking.rst

+90
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
Added a new :class:`pytest.Directory` base collection node, which all collector nodes for filesystem directories are expected to subclass.
2+
This is analogous to the existing :class:`pytest.File` for file nodes.
3+
4+
Changed :class:`pytest.Package` to be a subclass of :class:`pytest.Directory`.
5+
A ``Package`` represents a filesystem directory which is a Python package,
6+
i.e. contains an ``__init__.py`` file.
7+
8+
:class:`pytest.Package` now only collects files in its own directory; previously it collected recursively.
9+
Sub-directories are collected as sub-collector nodes, thus creating a collection tree which mirrors the filesystem hierarchy.
10+
11+
Added a new :class:`pytest.Dir` concrete collection node, a subclass of :class:`pytest.Directory`.
12+
This node represents a filesystem directory, which is not a :class:`pytest.Package`,
13+
i.e. does not contain an ``__init__.py`` file.
14+
Similarly to ``Package``, it only collects the files in its own directory,
15+
while collecting sub-directories as sub-collector nodes.
16+
17+
Added a new hook :hook:`pytest_collect_directory`,
18+
which is called by filesystem-traversing collector nodes,
19+
such as :class:`pytest.Session`, :class:`pytest.Dir` and :class:`pytest.Package`,
20+
to create a collector node for a sub-directory.
21+
It is expected to return a subclass of :class:`pytest.Directory`.
22+
This hook allows plugins to :ref:`customize the collection of directories <custom directory collectors>`.
23+
24+
:class:`pytest.Session` now only collects the initial arguments, without recursing into directories.
25+
This work is now done by the :func:`recursive expansion process <pytest.Collector.collect>` of directory collector nodes.
26+
27+
:attr:`session.name <pytest.Session.name>` is now ``""``; previously it was the rootdir directory name.
28+
This matches :attr:`session.nodeid <_pytest.nodes.Node.nodeid>` which has always been `""`.
29+
30+
Files and directories are now collected in alphabetical order jointly, unless changed by a plugin.
31+
Previously, files were collected before directories.
32+
33+
The collection tree now contains directories/packages up to the :ref:`rootdir <rootdir>`,
34+
for initial arguments that are found within the rootdir.
35+
For files outside the rootdir, only the immediate directory/package is collected --
36+
note however that collecting from outside the rootdir is discouraged.
37+
38+
As an example, given the following filesystem tree::
39+
40+
myroot/
41+
pytest.ini
42+
top/
43+
├── aaa
44+
│ └── test_aaa.py
45+
├── test_a.py
46+
├── test_b
47+
│ ├── __init__.py
48+
│ └── test_b.py
49+
├── test_c.py
50+
└── zzz
51+
├── __init__.py
52+
└── test_zzz.py
53+
54+
the collection tree, as shown by `pytest --collect-only top/` but with the otherwise-hidden :class:`~pytest.Session` node added for clarity,
55+
is now the following::
56+
57+
<Session>
58+
<Dir myroot>
59+
<Dir top>
60+
<Dir aaa>
61+
<Module test_aaa.py>
62+
<Function test_it>
63+
<Module test_a.py>
64+
<Function test_it>
65+
<Package test_b>
66+
<Module test_b.py>
67+
<Function test_it>
68+
<Module test_c.py>
69+
<Function test_it>
70+
<Package zzz>
71+
<Module test_zzz.py>
72+
<Function test_it>
73+
74+
Previously, it was::
75+
76+
<Session>
77+
<Module top/test_a.py>
78+
<Function test_it>
79+
<Module top/test_c.py>
80+
<Function test_it>
81+
<Module top/aaa/test_aaa.py>
82+
<Function test_it>
83+
<Package test_b>
84+
<Module test_b.py>
85+
<Function test_it>
86+
<Package zzz>
87+
<Module test_zzz.py>
88+
<Function test_it>
89+
90+
Code/plugins which rely on a specific shape of the collection tree might need to update.

doc/en/deprecations.rst

+85
Original file line numberDiff line numberDiff line change
@@ -495,6 +495,91 @@ an appropriate period of deprecation has passed.
495495
Some breaking changes which could not be deprecated are also listed.
496496

497497

498+
Collection changes in pytest 8
499+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
500+
501+
Added a new :class:`pytest.Directory` base collection node, which all collector nodes for filesystem directories are expected to subclass.
502+
This is analogous to the existing :class:`pytest.File` for file nodes.
503+
504+
Changed :class:`pytest.Package` to be a subclass of :class:`pytest.Directory`.
505+
A ``Package`` represents a filesystem directory which is a Python package,
506+
i.e. contains an ``__init__.py`` file.
507+
508+
:class:`pytest.Package` now only collects files in its own directory; previously it collected recursively.
509+
Sub-directories are collected as sub-collector nodes, thus creating a collection tree which mirrors the filesystem hierarchy.
510+
511+
:attr:`session.name <pytest.Session.name>` is now ``""``; previously it was the rootdir directory name.
512+
This matches :attr:`session.nodeid <_pytest.nodes.Node.nodeid>` which has always been `""`.
513+
514+
Added a new :class:`pytest.Dir` concrete collection node, a subclass of :class:`pytest.Directory`.
515+
This node represents a filesystem directory, which is not a :class:`pytest.Package`,
516+
i.e. does not contain an ``__init__.py`` file.
517+
Similarly to ``Package``, it only collects the files in its own directory,
518+
while collecting sub-directories as sub-collector nodes.
519+
520+
Files and directories are now collected in alphabetical order jointly, unless changed by a plugin.
521+
Previously, files were collected before directories.
522+
523+
The collection tree now contains directories/packages up to the :ref:`rootdir <rootdir>`,
524+
for initial arguments that are found within the rootdir.
525+
For files outside the rootdir, only the immediate directory/package is collected --
526+
note however that collecting from outside the rootdir is discouraged.
527+
528+
As an example, given the following filesystem tree::
529+
530+
myroot/
531+
pytest.ini
532+
top/
533+
├── aaa
534+
│ └── test_aaa.py
535+
├── test_a.py
536+
├── test_b
537+
│ ├── __init__.py
538+
│ └── test_b.py
539+
├── test_c.py
540+
└── zzz
541+
├── __init__.py
542+
└── test_zzz.py
543+
544+
the collection tree, as shown by `pytest --collect-only top/` but with the otherwise-hidden :class:`~pytest.Session` node added for clarity,
545+
is now the following::
546+
547+
<Session>
548+
<Dir myroot>
549+
<Dir top>
550+
<Dir aaa>
551+
<Module test_aaa.py>
552+
<Function test_it>
553+
<Module test_a.py>
554+
<Function test_it>
555+
<Package test_b>
556+
<Module test_b.py>
557+
<Function test_it>
558+
<Module test_c.py>
559+
<Function test_it>
560+
<Package zzz>
561+
<Module test_zzz.py>
562+
<Function test_it>
563+
564+
Previously, it was::
565+
566+
<Session>
567+
<Module top/test_a.py>
568+
<Function test_it>
569+
<Module top/test_c.py>
570+
<Function test_it>
571+
<Module top/aaa/test_aaa.py>
572+
<Function test_it>
573+
<Package test_b>
574+
<Module test_b.py>
575+
<Function test_it>
576+
<Package zzz>
577+
<Module test_zzz.py>
578+
<Function test_it>
579+
580+
Code/plugins which rely on a specific shape of the collection tree might need to update.
581+
582+
498583
:class:`pytest.Package` is no longer a :class:`pytest.Module` or :class:`pytest.File`
499584
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
500585

doc/en/example/conftest.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
collect_ignore = ["nonpython"]
1+
collect_ignore = ["nonpython", "customdirectory"]

doc/en/example/customdirectory.rst

+77
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
.. _`custom directory collectors`:
2+
3+
Using a custom directory collector
4+
====================================================
5+
6+
By default, pytest collects directories using :class:`pytest.Package`, for directories with ``__init__.py`` files,
7+
and :class:`pytest.Dir` for other directories.
8+
If you want to customize how a directory is collected, you can write your own :class:`pytest.Directory` collector,
9+
and use :hook:`pytest_collect_directory` to hook it up.
10+
11+
.. _`directory manifest plugin`:
12+
13+
A basic example for a directory manifest file
14+
--------------------------------------------------------------
15+
16+
Suppose you want to customize how collection is done on a per-directory basis.
17+
Here is an example ``conftest.py`` plugin that allows directories to contain a ``manifest.json`` file,
18+
which defines how the collection should be done for the directory.
19+
In this example, only a simple list of files is supported,
20+
however you can imagine adding other keys, such as exclusions and globs.
21+
22+
.. include:: customdirectory/conftest.py
23+
:literal:
24+
25+
You can create a ``manifest.json`` file and some test files:
26+
27+
.. include:: customdirectory/tests/manifest.json
28+
:literal:
29+
30+
.. include:: customdirectory/tests/test_first.py
31+
:literal:
32+
33+
.. include:: customdirectory/tests/test_second.py
34+
:literal:
35+
36+
.. include:: customdirectory/tests/test_third.py
37+
:literal:
38+
39+
An you can now execute the test specification:
40+
41+
.. code-block:: pytest
42+
43+
customdirectory $ pytest
44+
=========================== test session starts ============================
45+
platform linux -- Python 3.x.y, pytest-8.x.y, pluggy-1.x.y
46+
rootdir: /home/sweet/project/customdirectory
47+
configfile: pytest.ini
48+
collected 2 items
49+
50+
tests/test_first.py . [ 50%]
51+
tests/test_second.py . [100%]
52+
53+
============================ 2 passed in 0.12s =============================
54+
55+
.. regendoc:wipe
56+
57+
Notice how ``test_three.py`` was not executed, because it is not listed in the manifest.
58+
59+
You can verify that your custom collector appears in the collection tree:
60+
61+
.. code-block:: pytest
62+
63+
customdirectory $ pytest --collect-only
64+
=========================== test session starts ============================
65+
platform linux -- Python 3.x.y, pytest-8.x.y, pluggy-1.x.y
66+
rootdir: /home/sweet/project/customdirectory
67+
configfile: pytest.ini
68+
collected 2 items
69+
70+
<Dir customdirectory>
71+
<ManifestDirectory tests>
72+
<Module test_first.py>
73+
<Function test_1>
74+
<Module test_second.py>
75+
<Function test_2>
76+
77+
======================== 2 tests collected in 0.12s ========================
+28
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
# content of conftest.py
2+
import json
3+
4+
import pytest
5+
6+
7+
class ManifestDirectory(pytest.Directory):
8+
def collect(self):
9+
# The standard pytest behavior is to loop over all `test_*.py` files and
10+
# call `pytest_collect_file` on each file. This collector instead reads
11+
# the `manifest.json` file and only calls `pytest_collect_file` for the
12+
# files defined there.
13+
manifest_path = self.path / "manifest.json"
14+
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
15+
ihook = self.ihook
16+
for file in manifest["files"]:
17+
yield from ihook.pytest_collect_file(
18+
file_path=self.path / file, parent=self
19+
)
20+
21+
22+
@pytest.hookimpl
23+
def pytest_collect_directory(path, parent):
24+
# Use our custom collector for directories containing a `mainfest.json` file.
25+
if path.joinpath("manifest.json").is_file():
26+
return ManifestDirectory.from_parent(parent=parent, path=path)
27+
# Otherwise fallback to the standard behavior.
28+
return None

doc/en/example/customdirectory/pytest.ini

Whitespace-only changes.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
{
2+
"files": [
3+
"test_first.py",
4+
"test_second.py"
5+
]
6+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# content of test_first.py
2+
def test_1():
3+
pass
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# content of test_second.py
2+
def test_2():
3+
pass
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# content of test_third.py
2+
def test_3():
3+
pass

doc/en/example/index.rst

+1
Original file line numberDiff line numberDiff line change
@@ -32,3 +32,4 @@ The following examples aim at various use cases you might encounter.
3232
special
3333
pythoncollection
3434
nonpython
35+
customdirectory

doc/en/reference/reference.rst

+14
Original file line numberDiff line numberDiff line change
@@ -682,6 +682,8 @@ Collection hooks
682682
.. autofunction:: pytest_collection
683683
.. hook:: pytest_ignore_collect
684684
.. autofunction:: pytest_ignore_collect
685+
.. hook:: pytest_collect_directory
686+
.. autofunction:: pytest_collect_directory
685687
.. hook:: pytest_collect_file
686688
.. autofunction:: pytest_collect_file
687689
.. hook:: pytest_pycollect_makemodule
@@ -921,6 +923,18 @@ Config
921923
.. autoclass:: pytest.Config()
922924
:members:
923925

926+
Dir
927+
~~~
928+
929+
.. autoclass:: pytest.Dir()
930+
:members:
931+
932+
Directory
933+
~~~~~~~~~
934+
935+
.. autoclass:: pytest.Directory()
936+
:members:
937+
924938
ExceptionInfo
925939
~~~~~~~~~~~~~
926940

src/_pytest/cacheprovider.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,8 @@
2727
from _pytest.fixtures import fixture
2828
from _pytest.fixtures import FixtureRequest
2929
from _pytest.main import Session
30+
from _pytest.nodes import Directory
3031
from _pytest.nodes import File
31-
from _pytest.python import Package
3232
from _pytest.reports import TestReport
3333

3434
README_CONTENT = """\
@@ -222,7 +222,7 @@ def pytest_make_collect_report(
222222
self, collector: nodes.Collector
223223
) -> Generator[None, CollectReport, CollectReport]:
224224
res = yield
225-
if isinstance(collector, (Session, Package)):
225+
if isinstance(collector, (Session, Directory)):
226226
# Sort any lf-paths to the beginning.
227227
lf_paths = self.lfplugin._last_failed_paths
228228

src/_pytest/config/__init__.py

-2
Original file line numberDiff line numberDiff line change
@@ -415,8 +415,6 @@ def __init__(self) -> None:
415415
# session (#9478), often with the same path, so cache it.
416416
self._get_directory = lru_cache(256)(_get_directory)
417417

418-
self._duplicatepaths: Set[Path] = set()
419-
420418
# plugins that were explicitly skipped with pytest.skip
421419
# list of (module name, skip reason)
422420
# previously we would issue a warning when a plugin was skipped, but

0 commit comments

Comments
 (0)