From c76ae74bd79e8db9ff08996a86bc4f1cfdc1be48 Mon Sep 17 00:00:00 2001
From: Ran Benita <ran@unusedvar.com>
Date: Tue, 30 May 2023 09:29:19 +0300
Subject: [PATCH] cacheprovider: fix file-skipping functionality across
 packages

Continuation of fc538c5766a1c67bfcd704288279ceac5e20070a.
Fixes #11054 again.
---
 src/_pytest/cacheprovider.py  | 26 +++++++++++++++++++-------
 testing/test_cacheprovider.py | 27 +++++++++++++++++++++++++++
 2 files changed, 46 insertions(+), 7 deletions(-)

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: