Repository: pytest-dev/pytest
Issue: cacheprovider: `--lf` file-skipping feature doesn't work for files in packages

`--lf` has a feature where if a certain `Module` (python file) does not contain any failed tests, it is skipped entirely at the collector level instead of being collected and each item skipped individually. When this happens the collection summary looks like this:

    run-last-failure: rerun previous 1 failure (skipped 1 file)

However, this feature doesn't work for `Module`s inside of `Package`s, only for those directly beneath the `Session`.

Changed files:
src/_pytest/cacheprovider.py, testing/test_cacheprovider.py

diff --git a/src/_pytest/cacheprovider.py b/src/_pytest/cacheprovider.py
index 84ca2c688..3d4fb1d6c 100755
--- a/src/_pytest/cacheprovider.py
+++ b/src/_pytest/cacheprovider.py
@@ -226,10 +226,18 @@ class LFPluginCollWrapper:
             # Sort any lf-paths to the beginning.
             lf_paths = self.lfplugin._last_failed_paths
 
+            # Use stable sort to priorize last failed.
+            def sort_key(node: Union[nodes.Item, nodes.Collector]) -> bool:
+                # Package.path is the __init__.py file, we need the directory.
+                if isinstance(node, Package):
+                    path = node.path.parent
+                else:
+                    path = node.path
+                return path in lf_paths
+
             res.result = sorted(
                 res.result,
-                # use stable sort to priorize last failed
-                key=lambda x: x.path in lf_paths,
+                key=sort_key,
                 reverse=True,
             )
             return
@@ -272,9 +280,8 @@ class LFPluginCollSkipfiles:
     def pytest_make_collect_report(
         self, collector: nodes.Collector
     ) -> Optional[CollectReport]:
-        # Packages are Modules, but _last_failed_paths only contains
-        # test-bearing paths and doesn't try to include the paths of their
-        # packages, so don't filter them.
+        # Packages are Modules, but we only want to skip test-bearing Modules,
+        # so don't filter Packages.
         if isinstance(collector, Module) and not isinstance(collector, Package):
             if collector.path not in self.lfplugin._last_failed_paths:
                 self.lfplugin._skipped_files += 1
@@ -305,9 +312,14 @@ class LFPlugin:
             )
 
     def get_last_failed_paths(self) -> Set[Path]:
-        """Return a set with all Paths()s of the previously failed nodeids."""
+        """Return a set with all Paths of the previously failed nodeids and
+        their parents."""
         rootpath = self.config.rootpath
-        result = {rootpath / nodeid.split("::")[0] for nodeid in self.lastfailed}
+        result = set()
+        for nodeid in self.lastfailed:
+            path = rootpath / nodeid.split("::")[0]
+            result.add(path)
+            result.update(path.parents)
         return {x for x in result if x.exists()}
 
     def pytest_report_collectionfinish(self) -> Optional[str]:
diff --git a/testing/test_cacheprovider.py b/testing/test_cacheprovider.py
index 7c6606e2b..cb8011036 100644
--- a/testing/test_cacheprovider.py
+++ b/testing/test_cacheprovider.py
@@ -854,6 +854,33 @@ class TestLastFailed:
             ]
         )
 
+    def test_lastfailed_skip_collection_with_nesting(self, pytester: Pytester) -> None:
+        """Check that file skipping works even when the file with failures is
+        nested at a different level of the collection tree."""
+        pytester.makepyfile(
+            **{
+                "test_1.py": """
+                    def test_1(): pass
+                """,
+                "pkg/__init__.py": "",
+                "pkg/test_2.py": """
+                    def test_2(): assert False
+                """,
+            }
+        )
+        # first run
+        result = pytester.runpytest()
+        result.stdout.fnmatch_lines(["collected 2 items", "*1 failed*1 passed*"])
+        # second run - test_1.py is skipped.
+        result = pytester.runpytest("--lf")
+        result.stdout.fnmatch_lines(
+            [
+                "collected 1 item",
+                "run-last-failure: rerun previous 1 failure (skipped 1 file)",
+                "*= 1 failed in *",
+            ]
+        )
+
     def test_lastfailed_with_known_failures_not_being_selected(
         self, pytester: Pytester
     ) -> None: