Repository: pantsbuild/pants

Issue:
Cycles involving file addresses not always permitted
A case like:
```
pants.engine.internals.graph.CycleException: Dependency graph contained a cycle:
-> src/python/example/migrations:migrations
   src/python/example/__init__.py
-> src/python/example/migrations:migrations
```
...can currently fail. And this can be triggered by #10441.

A workaround is to split the `__init__.py` file out into its own target, but this shouldn't be necessary.


Discussion:

Resolving patch:
diff --git a/src/python/pants/engine/internals/graph.py b/src/python/pants/engine/internals/graph.py
--- a/src/python/pants/engine/internals/graph.py
+++ b/src/python/pants/engine/internals/graph.py
@@ -161,11 +161,31 @@ def _detect_cycles(
     path_stack: OrderedSet[Address] = OrderedSet()
     visited: Set[Address] = set()
 
+    def maybe_report_cycle(address: Address) -> None:
+        # NB: File-level dependencies are cycle tolerant.
+        if address.generated_base_target_name or address not in path_stack:
+            return
+
+        # The path of the cycle is shorter than the entire path to the cycle: if the suffix of
+        # the path representing the cycle contains a file dep, it is ignored.
+        in_cycle = False
+        for path_address in path_stack:
+            if in_cycle and path_address.generated_base_target_name:
+                # There is a file address inside the cycle: do not report it.
+                return
+            elif in_cycle:
+                # Not a file address.
+                continue
+            else:
+                # We're entering the suffix of the path that contains the cycle if we've reached
+                # the address in question.
+                in_cycle = path_address == address
+        # If we did not break out early, it's because there were no file addresses in the cycle.
+        raise CycleException(address, (*path_stack, address))
+
     def visit(address: Address):
         if address in visited:
-            # NB: File-level dependencies are cycle tolerant.
-            if not address.generated_base_target_name and address in path_stack:
-                raise CycleException(address, (*path_stack, address))
+            maybe_report_cycle(address)
             return
         path_stack.add(address)
         visited.add(address)


Test patch:
diff --git a/src/python/pants/engine/internals/graph_test.py b/src/python/pants/engine/internals/graph_test.py
--- a/src/python/pants/engine/internals/graph_test.py
+++ b/src/python/pants/engine/internals/graph_test.py
@@ -205,6 +205,28 @@ def test_cycle_indirect(self) -> None:
         self.assert_failed_cycle("//:t1", "//:t2", ("//:t1", "//:t2", "//:t3", "//:t2"))
         self.assert_failed_cycle("//:t2", "//:t2", ("//:t2", "//:t3", "//:t2"))
 
+    def test_nocycle_indirect(self) -> None:
+        self.create_files("", ["t2.txt"])
+        self.add_to_build_file(
+            "",
+            dedent(
+                """\
+                target(name='t1', dependencies=['t2.txt'])
+                target(name='t2', dependencies=[':t1'], sources=['t2.txt'])
+                """
+            ),
+        )
+        result = self.request_single_product(
+            TransitiveTargets,
+            Params(Addresses([Address.parse("//:t1")]), create_options_bootstrapper()),
+        )
+        assert len(result.roots) == 1
+        assert result.roots[0].address == Address.parse("//:t1")
+        assert {tgt.address for tgt in result.dependencies} == {
+            Address.parse("//:t1"),
+            Address("", target_name="t2.txt", generated_base_target_name="t2"),
+        }
+
     def test_resolve_generated_subtarget(self) -> None:
         self.add_to_build_file("demo", "target(sources=['f1.txt', 'f2.txt'])")
         generated_target_addresss = Address(