Skip to content

Commit a21fb87

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

File tree

6 files changed

+149
-140
lines changed

6 files changed

+149
-140
lines changed

changelog/11137.breaking.rst

+11
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

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

497497

498+
:class:`pytest.Package` is no longer a :class:`pytest.Module` or :class:`pytest.File`
499+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
500+
501+
.. versionchanged:: 8.0
502+
503+
The ``Package`` collector node designates a Python package, that is, a directory with an `__init__.py` file.
504+
Previously ``Package`` was a subtype of ``pytest.Module`` (which represents a single Python module),
505+
the module being the `__init__.py` file.
506+
This has been deemed a design mistake (see :issue:`11137` and :issue:`7777` for details).
507+
508+
The ``path`` property of ``Package`` nodes now points to the package directory instead of the ``__init__.py`` file.
509+
510+
Note that a ``Module`` node for ``__init__.py`` (which is not a ``Package``) may still exist,
511+
if it is picked up during collection (e.g. if you configured :confval:`python_files` to include ``__init__.py`` files).
512+
513+
498514
Collecting ``__init__.py`` files no longer collects package
499515
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
500516

src/_pytest/cacheprovider.py

+2-9
Original file line numberDiff line numberDiff line change
@@ -228,12 +228,7 @@ def pytest_make_collect_report(
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,
@@ -277,9 +272,7 @@ def __init__(self, lfplugin: "LFPlugin") -> None:
277272
def pytest_make_collect_report(
278273
self, collector: nodes.Collector
279274
) -> Optional[CollectReport]:
280-
# Packages are Files, but we only want to skip test-bearing Files,
281-
# so don't filter Packages.
282-
if isinstance(collector, File) and not isinstance(collector, Package):
275+
if isinstance(collector, File):
283276
if collector.path not in self.lfplugin._last_failed_paths:
284277
self.lfplugin._skipped_files += 1
285278

src/_pytest/fixtures.py

+2-3
Original file line numberDiff line numberDiff line change
@@ -119,9 +119,8 @@ def get_scope_package(
119119
from _pytest.python import Package
120120

121121
current: Optional[Union[nodes.Item, nodes.Collector]] = node
122-
fixture_package_name = "{}/{}".format(fixturedef.baseid, "__init__.py")
123122
while current and (
124-
not isinstance(current, Package) or fixture_package_name != current.nodeid
123+
not isinstance(current, Package) or current.nodeid != fixturedef.baseid
125124
):
126125
current = current.parent # type: ignore[assignment]
127126
if current is None:
@@ -263,7 +262,7 @@ def get_parametrized_fixture_keys(item: nodes.Item, scope: Scope) -> Iterator[_K
263262
if scope is Scope.Session:
264263
key: _Key = (argname, param_index)
265264
elif scope is Scope.Package:
266-
key = (argname, param_index, item.path.parent)
265+
key = (argname, param_index, item.path)
267266
elif scope is Scope.Module:
268267
key = (argname, param_index, item.path)
269268
elif scope is Scope.Class:

src/_pytest/main.py

+51-59
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,9 @@
1717
from typing import Optional
1818
from typing import overload
1919
from typing import Sequence
20-
from typing import Set
2120
from typing import Tuple
2221
from typing import Type
22+
from typing import TYPE_CHECKING
2323
from typing import Union
2424

2525
import _pytest._code
@@ -43,6 +43,10 @@
4343
from _pytest.runner import SetupState
4444

4545

46+
if TYPE_CHECKING:
47+
from _pytest.python import Package
48+
49+
4650
def pytest_addoption(parser: Parser) -> None:
4751
parser.addini(
4852
"norecursedirs",
@@ -572,6 +576,17 @@ def _recurse(self, direntry: "os.DirEntry[str]") -> bool:
572576
return False
573577
return True
574578

579+
def _collectpackage(self, fspath: Path) -> Optional["Package"]:
580+
from _pytest.python import Package
581+
582+
ihook = self.gethookproxy(fspath)
583+
if not self.isinitpath(fspath):
584+
if ihook.pytest_ignore_collect(collection_path=fspath, config=self.config):
585+
return None
586+
587+
pkg: Package = Package.from_parent(self, path=fspath)
588+
return pkg
589+
575590
def _collectfile(
576591
self, fspath: Path, handle_dupes: bool = True
577592
) -> Sequence[nodes.Collector]:
@@ -680,8 +695,6 @@ def perform_collect( # noqa: F811
680695
return items
681696

682697
def collect(self) -> Iterator[Union[nodes.Item, nodes.Collector]]:
683-
from _pytest.python import Package
684-
685698
# Keep track of any collected nodes in here, so we don't duplicate fixtures.
686699
node_cache1: Dict[Path, Sequence[nodes.Collector]] = {}
687700
node_cache2: Dict[Tuple[Type[nodes.Collector], Path], nodes.Collector] = {}
@@ -691,63 +704,57 @@ def collect(self) -> Iterator[Union[nodes.Item, nodes.Collector]]:
691704
matchnodes_cache: Dict[Tuple[Type[nodes.Collector], str], CollectReport] = {}
692705

693706
# Directories of pkgs with dunder-init files.
694-
pkg_roots: Dict[Path, Package] = {}
707+
pkg_roots: Dict[Path, "Package"] = {}
708+
709+
pm = self.config.pluginmanager
695710

696711
for argpath, names in self._initial_parts:
697712
self.trace("processing argument", (argpath, names))
698713
self.trace.root.indent += 1
699714

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

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

723-
seen_dirs: Set[Path] = set()
724-
for direntry in visit(argpath, self._recurse):
725-
if not direntry.is_file():
726-
continue
734+
if argpath in pkg_roots:
735+
yield pkg_roots[argpath]
727736

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

@@ -806,21 +813,6 @@ def collect(self) -> Iterator[Union[nodes.Item, nodes.Collector]]:
806813
self._notfound.append((report_arg, col))
807814
continue
808815

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

826818
self.trace.root.indent -= 1

0 commit comments

Comments
 (0)