Repository: pantsbuild/pants

Issue:
Rule graph issue if union member is used in a `Get`
In `pytest_runner.py`, we take `TestTargetSetup` as a param to the test rule. The rule to generate `TestTargetSetup` takes `PythonTestFieldSet` as a param too, in addition to the test rule using it.

https://github.com/pantsbuild/pants/blob/ed40678b1338c81a67a3148589bf5cc73de8abaf/src/python/pants/backend/python/rules/pytest_runner.py#L191-L197

If we move the `TestTargetSetup ` param out of the params list and into `await Get(TestTargetSetup, PythonTestFieldSet, field_set)`, then Pants fails with:

```
Engine traceback:
  in select
  in `test` goal
  in Run test target
  in Run Pytest
Traceback (no traceback):
  <pants native internals>
Exception: Type PythonTestFieldSet is not a member of the TestFieldSet @union ("The fields necessary to run tests on a target.")
```
@union decorator results in strange classnames
The type generated by the `@union` decorator is declared inside `pants.util.meta` likely due to being implemented via https://github.com/pantsbuild/pants/blob/21e76ad3e1b13aeab419096e62999bf3523c7c39/src/python/pants/util/meta.py#L145-L164

Meaning that regardless of which module a class like:
```
@union
class MyUnion: pass
```
... is declared in, it will have a name like:
```
pants.util.meta.MyUnion
```


Discussion:
The error message mixup is likely because `PythonTestFieldSet` extends `TestFieldSet`, and the markers used by `@union` end up inherited by the subclass. `externs::is_union` and `non_member_error_message` thus end up inherited by the child class as well.
That makes a lot of sense. So we likely need to figure out how to have the union not be inherited by its children. Let's see if Python has a mechanism for that..
There's a mechanism, but it's not 100% pleasant. I'll have a go at implementing it this way, and then see if we can't improve on it: https://pantsbuild.slack.com/archives/C0D7TNJHL/p1628707326112800?thread_ts=1628707039.111800&cid=C0D7TNJHL


Resolving patch:
diff --git a/src/python/pants/engine/unions.py b/src/python/pants/engine/unions.py
--- a/src/python/pants/engine/unions.py
+++ b/src/python/pants/engine/unions.py
@@ -8,11 +8,10 @@
 from typing import DefaultDict, Iterable, Mapping, TypeVar
 
 from pants.util.frozendict import FrozenDict
-from pants.util.meta import decorated_type_checkable, frozen_after_init
+from pants.util.meta import frozen_after_init
 from pants.util.ordered_set import FrozenOrderedSet, OrderedSet
 
 
-@decorated_type_checkable
 def union(cls):
     """A class decorator to allow a class to be a union base in the engine's mechanism for
     polymorphism.
@@ -27,14 +26,15 @@ def union(cls):
 
     See https://www.pantsbuild.org/docs/rules-api-unions.
     """
-    # TODO: Check that the union base type is used as a tag and nothing else (e.g. no attributes)!
     assert isinstance(cls, type)
-    return union.define_instance_of(cls)
+    cls._is_union_for = cls
+    return cls
 
 
 def is_union(input_type: type) -> bool:
     """Return whether or not a type has been annotated with `@union`."""
-    return union.is_instance(input_type)
+    is_union: bool = input_type == getattr(input_type, "_is_union_for", None)
+    return is_union
 
 
 @dataclass(frozen=True)
@@ -46,12 +46,12 @@ class UnionRule:
     union_member: type
 
     def __post_init__(self) -> None:
-        if not union.is_instance(self.union_base):
+        if not is_union(self.union_base):
             msg = (
                 f"The first argument must be a class annotated with @union "
                 f"(from pants.engine.unions), but was {self.union_base}."
             )
-            if union.is_instance(self.union_member):
+            if is_union(self.union_member):
                 msg += (
                     "\n\nHowever, the second argument was annotated with `@union`. Did you "
                     "switch the first and second arguments to `UnionRule()`?"


Test patch:
diff --git a/src/python/pants/engine/internals/engine_test.py b/src/python/pants/engine/internals/engine_test.py
--- a/src/python/pants/engine/internals/engine_test.py
+++ b/src/python/pants/engine/internals/engine_test.py
@@ -38,6 +38,7 @@
     TargetInfo,
     WorkunitsCallback,
 )
+from pants.engine.unions import UnionRule, union
 from pants.goal.run_tracker import RunTracker
 from pants.testutil.option_util import create_options_bootstrapper
 from pants.testutil.rule_runner import QueryRule, RuleRunner
@@ -942,3 +943,36 @@ def __call__(self, **kwargs) -> None:
     )
     with handler:
         rule_runner.request(ProcessResult, [stdout_process])
+
+
+@union
+class Union:
+    pass
+
+
+class Member(Union):
+    pass
+
+
+def test_union_member_construction(run_tracker: RunTracker) -> None:
+    """Use a union member which is a subclass of its @union as a Get input."""
+
+    @rule
+    async def output(_: Member) -> str:
+        return "yep"
+
+    @rule
+    async def for_member() -> str:
+        return await Get(str, Member())
+
+    rule_runner = RuleRunner(
+        target_types=[],
+        rules=[
+            UnionRule(Union, Member),
+            QueryRule(str, ()),
+            output,
+            for_member,
+        ],
+    )
+
+    assert "yep" == rule_runner.request(str, [])