Skip to content

Commit 72457dc

Browse files
committed
python: change Package to no longer be a Module/File
Fix #11137.
1 parent fe51121 commit 72457dc

File tree

6 files changed

+145
-139
lines changed

6 files changed

+145
-139
lines changed

changelog/11137.breaking.rst

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
:class:`pytest.Package` is no longer a :class:`pytest.Module` or :class:`pytest.File`.
2+
3+
The ``Package`` collector node designates a Python package, that is, a directory with an `__init__.py` file.
4+
Previously ``Package`` was a subtype of ``pytest.Module`` (which represents a single Python module),
5+
the module being the `__init__.py` file.
6+
This has been deemed a design mistake (see :issue:`11137` and :issue:`7777` for details).
7+
8+
The ``path`` property of ``Package`` nodes now points to the package directory instead of the ``__init__.py`` file.
9+
10+
Note that a ``Module`` node for ``__init__.py`` (which is not a ``Package``) may still exist,
11+
if it is picked up during collection (e.g. if you configured :confval:`python_files` to include ``__init__.py`` files).

doc/en/deprecations.rst

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -476,6 +476,22 @@ an appropriate period of deprecation has passed.
476476
Some breaking changes which could not be deprecated are also listed.
477477

478478

479+
:class:`pytest.Package` is no longer a :class:`pytest.Module` or :class:`pytest.File`
480+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
481+
482+
.. versionchanged:: 8.0
483+
484+
The ``Package`` collector node designates a Python package, that is, a directory with an `__init__.py` file.
485+
Previously ``Package`` was a subtype of ``pytest.Module`` (which represents a single Python module),
486+
the module being the `__init__.py` file.
487+
This has been deemed a design mistake (see :issue:`11137` and :issue:`7777` for details).
488+
489+
The ``path`` property of ``Package`` nodes now points to the package directory instead of the ``__init__.py`` file.
490+
491+
Note that a ``Module`` node for ``__init__.py`` (which is not a ``Package``) may still exist,
492+
if it is picked up during collection (e.g. if you configured :confval:`python_files` to include ``__init__.py`` files).
493+
494+
479495
Collecting ``__init__.py`` files no longer collects package
480496
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
481497

src/_pytest/cacheprovider.py

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -228,12 +228,7 @@ def pytest_make_collect_report(self, collector: nodes.Collector):
228228

229229
# Use stable sort to priorize last failed.
230230
def sort_key(node: Union[nodes.Item, nodes.Collector]) -> bool:
231-
# Package.path is the __init__.py file, we need the directory.
232-
if isinstance(node, Package):
233-
path = node.path.parent
234-
else:
235-
path = node.path
236-
return path in lf_paths
231+
return node.path in lf_paths
237232

238233
res.result = sorted(
239234
res.result,
@@ -280,9 +275,7 @@ def __init__(self, lfplugin: "LFPlugin") -> None:
280275
def pytest_make_collect_report(
281276
self, collector: nodes.Collector
282277
) -> Optional[CollectReport]:
283-
# Packages are Files, but we only want to skip test-bearing Files,
284-
# so don't filter Packages.
285-
if isinstance(collector, File) and not isinstance(collector, Package):
278+
if isinstance(collector, File):
286279
if collector.path not in self.lfplugin._last_failed_paths:
287280
self.lfplugin._skipped_files += 1
288281

src/_pytest/fixtures.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -120,9 +120,8 @@ def get_scope_package(
120120
from _pytest.python import Package
121121

122122
current: Optional[Union[nodes.Item, nodes.Collector]] = node
123-
fixture_package_name = "{}/{}".format(fixturedef.baseid, "__init__.py")
124123
while current and (
125-
not isinstance(current, Package) or fixture_package_name != current.nodeid
124+
not isinstance(current, Package) or current.nodeid != fixturedef.baseid
126125
):
127126
current = current.parent # type: ignore[assignment]
128127
if current is None:

src/_pytest/main.py

Lines changed: 48 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@
1414
from typing import List
1515
from typing import Optional
1616
from typing import Sequence
17-
from typing import Set
1817
from typing import Tuple
1918
from typing import Type
2019
from typing import TYPE_CHECKING
@@ -46,6 +45,8 @@
4645
if TYPE_CHECKING:
4746
from typing_extensions import Literal
4847

48+
from _pytest.python import Package
49+
4950

5051
def pytest_addoption(parser: Parser) -> None:
5152
parser.addini(
@@ -571,6 +572,17 @@ def _recurse(self, direntry: "os.DirEntry[str]") -> bool:
571572
return False
572573
return True
573574

575+
def _collectpackage(self, fspath: Path) -> Optional["Package"]:
576+
from _pytest.python import Package
577+
578+
ihook = self.gethookproxy(fspath)
579+
if not self.isinitpath(fspath):
580+
if ihook.pytest_ignore_collect(collection_path=fspath, config=self.config):
581+
return None
582+
583+
pkg: Package = Package.from_parent(self, path=fspath)
584+
return pkg
585+
574586
def _collectfile(
575587
self, fspath: Path, handle_dupes: bool = True
576588
) -> Sequence[nodes.Collector]:
@@ -679,8 +691,6 @@ def perform_collect( # noqa: F811
679691
return items
680692

681693
def collect(self) -> Iterator[Union[nodes.Item, nodes.Collector]]:
682-
from _pytest.python import Package
683-
684694
# Keep track of any collected nodes in here, so we don't duplicate fixtures.
685695
node_cache1: Dict[Path, Sequence[nodes.Collector]] = {}
686696
node_cache2: Dict[Tuple[Type[nodes.Collector], Path], nodes.Collector] = {}
@@ -690,63 +700,57 @@ def collect(self) -> Iterator[Union[nodes.Item, nodes.Collector]]:
690700
matchnodes_cache: Dict[Tuple[Type[nodes.Collector], str], CollectReport] = {}
691701

692702
# Directories of pkgs with dunder-init files.
693-
pkg_roots: Dict[Path, Package] = {}
703+
pkg_roots: Dict[Path, "Package"] = {}
704+
705+
pm = self.config.pluginmanager
694706

695707
for argpath, names in self._initial_parts:
696708
self.trace("processing argument", (argpath, names))
697709
self.trace.root.indent += 1
698710

699711
# Start with a Session root, and delve to argpath item (dir or file)
700712
# and stack all Packages found on the way.
701-
# No point in finding packages when collecting doctests.
702-
if not self.config.getoption("doctestmodules", False):
703-
pm = self.config.pluginmanager
704-
for parent in (argpath, *argpath.parents):
705-
if not pm._is_in_confcutdir(argpath):
706-
break
707-
708-
if parent.is_dir():
709-
pkginit = parent / "__init__.py"
710-
if pkginit.is_file() and pkginit not in node_cache1:
711-
col = self._collectfile(pkginit, handle_dupes=False)
712-
if col:
713-
if isinstance(col[0], Package):
714-
pkg_roots[parent] = col[0]
715-
node_cache1[col[0].path] = [col[0]]
713+
for parent in (argpath, *argpath.parents):
714+
if not pm._is_in_confcutdir(argpath):
715+
break
716+
717+
if parent.is_dir():
718+
pkginit = parent / "__init__.py"
719+
if pkginit.is_file() and parent not in node_cache1:
720+
pkg = self._collectpackage(parent)
721+
if pkg is not None:
722+
pkg_roots[parent] = pkg
723+
node_cache1[pkg.path] = [pkg]
716724

717725
# If it's a directory argument, recurse and look for any Subpackages.
718726
# Let the Package collector deal with subnodes, don't collect here.
719727
if argpath.is_dir():
720728
assert not names, f"invalid arg {(argpath, names)!r}"
721729

722-
seen_dirs: Set[Path] = set()
723-
for direntry in visit(argpath, self._recurse):
724-
if not direntry.is_file():
725-
continue
730+
if argpath in pkg_roots:
731+
yield pkg_roots[argpath]
726732

733+
for direntry in visit(argpath, self._recurse):
727734
path = Path(direntry.path)
728-
dirpath = path.parent
729-
730-
if dirpath not in seen_dirs:
731-
# Collect packages first.
732-
seen_dirs.add(dirpath)
733-
pkginit = dirpath / "__init__.py"
734-
if pkginit.exists():
735-
for x in self._collectfile(pkginit):
735+
if direntry.is_dir() and self._recurse(direntry):
736+
pkginit = path / "__init__.py"
737+
if pkginit.is_file():
738+
pkg = self._collectpackage(path)
739+
if pkg is not None:
740+
yield pkg
741+
pkg_roots[path] = pkg
742+
743+
elif direntry.is_file():
744+
if path.parent in pkg_roots:
745+
# Package handles this file.
746+
continue
747+
for x in self._collectfile(path):
748+
key2 = (type(x), x.path)
749+
if key2 in node_cache2:
750+
yield node_cache2[key2]
751+
else:
752+
node_cache2[key2] = x
736753
yield x
737-
if isinstance(x, Package):
738-
pkg_roots[dirpath] = x
739-
if dirpath in pkg_roots:
740-
# Do not collect packages here.
741-
continue
742-
743-
for x in self._collectfile(path):
744-
key2 = (type(x), x.path)
745-
if key2 in node_cache2:
746-
yield node_cache2[key2]
747-
else:
748-
node_cache2[key2] = x
749-
yield x
750754
else:
751755
assert argpath.is_file()
752756

@@ -805,21 +809,6 @@ def collect(self) -> Iterator[Union[nodes.Item, nodes.Collector]]:
805809
self._notfound.append((report_arg, col))
806810
continue
807811

808-
# If __init__.py was the only file requested, then the matched
809-
# node will be the corresponding Package (by default), and the
810-
# first yielded item will be the __init__ Module itself, so
811-
# just use that. If this special case isn't taken, then all the
812-
# files in the package will be yielded.
813-
if argpath.name == "__init__.py" and isinstance(matching[0], Package):
814-
try:
815-
yield next(iter(matching[0].collect()))
816-
except StopIteration:
817-
# The package collects nothing with only an __init__.py
818-
# file in it, which gets ignored by the default
819-
# "python_files" option.
820-
pass
821-
continue
822-
823812
yield from matching
824813

825814
self.trace.root.indent -= 1

0 commit comments

Comments
 (0)