Repository: pantsbuild/pants

Issue:
`collect_rules()` should error when it encounters duplicate named rules in the file
A few times rule authors have been confused with rule graph errors that are because two rules have the same name, which causes the first rule to be overridden & not be used. This is extremely confusing.

`collect_rules()` should detect this case and eagerly error.

https://github.com/pantsbuild/pants/blob/4fecd1d93587d67dc8b46fa6acbd9a3aac9c348f/src/python/pants/engine/rules.py#L366-L394


Discussion:
This is a Python issue, right? Unless we switch to parsing the AST of the file, Python overrides the globals dict in this way. 
Yeah good point. @tdyas and I were discussing also that it's not sufficient to use a Flake8 or MyPy check here, as `./pants lint` and `./pants check` won't be able to load due to rule graph issues. Meaning, I think we'd need to add that AST check into `collect_rules()` as supplemental to its current logic.

This particular issue has happened at least two times to me, and at least once to Tom. All 3 times have been very confusing what's going on, and we're maintainers who know what to look for. So I suspect this AST complexity would be worth it to help plugin authors.
Maybe? But the same thing is true for every other Python file you author. What keeps you from getting tripped up in those files? Is there something about rules or the way we write them to cause more instances of dup function defs in the same file?
This is a feature after all! - Its why @overload works at all.
> Is there something about rules or the way we write them to cause more instances of dup function defs in the same file?
> This is a feature after all!

1. This is not a feature for `@rule`s, unlike `@overload` - there seems to be no practical reason you'd want to do this.
2. Failure is more extreme:  the rule graph is very likely to not work properly, so Pants can't start.
    - A normal Python function being overridden is likely to be buggy, but not necessarily cause an import/startup issue. Still bad, but those can be caught via things like linters/checkers.
    - Yes, we should also improve error messages so the failure is less confusing.
3. `@rule`s are in the purview of Pants, unlike normal functions. Even though it's likely a bug to overload normal functions (outside of MyPy), we shouldn't try checking your normal code for you. But plugin code, we can help you out.

In case adding this to the AST parsers is hard/complex, I can imagine doing something clever in the `@rule` decorator can catch this pro-actively at load time (like keeping a record of defined rules in the current scope, and err on dups). 
That is, it would err on the second `@rule` invocation with a dup name before even getting to `collect_rules()`.
+1 to @kaos's idea. That would be better I think both for complexity and likely perf. We've been treating cold perf as throwaway for a long time - pantsd or bust - but its getting worse and worse and option parsing (in Python) and rule collection have been historically slow.

Resolving patch:
diff --git a/src/python/pants/engine/rules.py b/src/python/pants/engine/rules.py
--- a/src/python/pants/engine/rules.py
+++ b/src/python/pants/engine/rules.py
@@ -4,6 +4,7 @@
 from __future__ import annotations
 
 import inspect
+import sys
 from abc import ABC, abstractmethod
 from dataclasses import dataclass
 from enum import Enum
@@ -34,6 +35,9 @@
 from pants.util.memo import memoized
 from pants.util.meta import frozen_after_init
 from pants.util.ordered_set import FrozenOrderedSet, OrderedSet
+from pants.util.strutil import softwrap
+
+PANTS_RULES_MODULE_KEY = "__pants_rules__"
 
 
 # NB: This violates Python naming conventions of using snake_case for functions. This is because
@@ -130,6 +134,10 @@ class MissingParameterTypeAnnotation(InvalidTypeAnnotation):
     """Indicates a missing parameter type annotation for an `@rule`."""
 
 
+class DuplicateRuleError(TypeError):
+    """Invalid to overwrite `@rule`s using the same name in the same module."""
+
+
 def _ensure_type_annotation(
     *,
     type_annotation: Optional[Type],
@@ -193,7 +201,9 @@ def rule_decorator(func, **kwargs) -> Callable:
 
     # Set a default canonical name if one is not explicitly provided to the module and name of the
     # function that implements it. This is used as the workunit name.
-    effective_name = kwargs.get("canonical_name", f"{func.__module__}.{func.__name__}")
+    effective_name = kwargs.get(
+        "canonical_name", f"{func.__module__}.{func.__qualname__}".replace(".<locals>", "")
+    )
 
     # Set a default description, which is used in the dynamic UI and stacktraces.
     effective_desc = kwargs.get("desc")
@@ -207,6 +217,27 @@ def rule_decorator(func, **kwargs) -> Callable:
             f"argument, but got: {effective_level}"
         )
 
+    module = sys.modules[func.__module__]
+    pants_rules = getattr(module, PANTS_RULES_MODULE_KEY, None)
+    if pants_rules is None:
+        pants_rules = {}
+        setattr(module, PANTS_RULES_MODULE_KEY, pants_rules)
+
+    if effective_name not in pants_rules:
+        pants_rules[effective_name] = func
+    else:
+        prev_func = pants_rules[effective_name]
+        if prev_func.__code__ != func.__code__:
+            raise DuplicateRuleError(
+                softwrap(
+                    f"""
+                    Redeclaring rule {effective_name} with {func} at line
+                    {func.__code__.co_firstlineno}, previously defined by {prev_func} at line
+                    {prev_func.__code__.co_firstlineno}.
+                    """
+                )
+            )
+
     return _make_rule(
         func_id,
         rule_type,


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
@@ -500,7 +500,10 @@ def a_rule(n: int) -> ModifiedOutput:
 
         finished = list(itertools.chain.from_iterable(tracker.finished_workunit_chunks))
         workunit = next(
-            item for item in finished if item["name"] == "pants.engine.internals.engine_test.a_rule"
+            item
+            for item in finished
+            if item["name"]
+            == "pants.engine.internals.engine_test.TestStreamingWorkunit.test_engine_aware_rule.a_rule"
         )
         assert workunit["level"] == "ERROR"
 
@@ -524,7 +527,10 @@ def a_rule(_: ModifiedMetadata) -> int:
 
         finished = list(itertools.chain.from_iterable(tracker.finished_workunit_chunks))
         workunit = next(
-            item for item in finished if item["name"] == "pants.engine.internals.engine_test.a_rule"
+            item
+            for item in finished
+            if item["name"]
+            == "pants.engine.internals.engine_test.TestStreamingWorkunit.test_engine_aware_param.a_rule"
         )
         assert workunit["metadata"] == {"example": "thing"}
 
@@ -553,7 +559,10 @@ def a_rule(n: int) -> ModifiedOutput:
 
         finished = list(itertools.chain.from_iterable(tracker.finished_workunit_chunks))
         workunit = next(
-            item for item in finished if item["name"] == "pants.engine.internals.engine_test.a_rule"
+            item
+            for item in finished
+            if item["name"]
+            == "pants.engine.internals.engine_test.TestStreamingWorkunit.test_engine_aware_none_case.a_rule"
         )
         assert workunit["level"] == "TRACE"
 
@@ -577,7 +586,10 @@ def a_rule(n: int) -> Output:
 
         finished = list(itertools.chain.from_iterable(tracker.finished_workunit_chunks))
         workunit = next(
-            item for item in finished if item["name"] == "pants.engine.internals.engine_test.a_rule"
+            item
+            for item in finished
+            if item["name"]
+            == "pants.engine.internals.engine_test.TestStreamingWorkunit.test_artifacts_on_engine_aware_type.a_rule"
         )
         artifacts = workunit["artifacts"]
         assert artifacts["some_arbitrary_key"] == EMPTY_SNAPSHOT
@@ -602,7 +614,10 @@ def a_rule(n: int) -> Output:
 
         finished = list(itertools.chain.from_iterable(tracker.finished_workunit_chunks))
         workunit = next(
-            item for item in finished if item["name"] == "pants.engine.internals.engine_test.a_rule"
+            item
+            for item in finished
+            if item["name"]
+            == "pants.engine.internals.engine_test.TestStreamingWorkunit.test_metadata_on_engine_aware_type.a_rule"
         )
 
         metadata = workunit["metadata"]
@@ -632,7 +647,10 @@ def a_rule(n: int) -> Output:
 
         finished = list(itertools.chain.from_iterable(tracker.finished_workunit_chunks))
         workunit = next(
-            item for item in finished if item["name"] == "pants.engine.internals.engine_test.a_rule"
+            item
+            for item in finished
+            if item["name"]
+            == "pants.engine.internals.engine_test.TestStreamingWorkunit.test_metadata_non_string_key_behavior.a_rule"
         )
 
         assert workunit["metadata"] == {}
diff --git a/src/python/pants/engine/rules_test.py b/src/python/pants/engine/rules_test.py
--- a/src/python/pants/engine/rules_test.py
+++ b/src/python/pants/engine/rules_test.py
@@ -16,6 +16,7 @@
 from pants.engine.internals.native_engine import PyExecutor
 from pants.engine.internals.scheduler import Scheduler
 from pants.engine.rules import (
+    DuplicateRuleError,
     Get,
     MissingParameterTypeAnnotation,
     MissingReturnTypeAnnotation,
@@ -316,7 +317,10 @@ def a_named_rule(a: int, b: str) -> bool:
             return False
 
         assert a_named_rule.rule is not None
-        assert a_named_rule.rule.canonical_name == "pants.engine.rules_test.a_named_rule"
+        assert (
+            a_named_rule.rule.canonical_name
+            == "pants.engine.rules_test.TestRuleArgumentAnnotation.test_annotations_kwargs.a_named_rule"
+        )
         assert a_named_rule.rule.desc is None
         assert a_named_rule.rule.level == LogLevel.INFO
 
@@ -992,6 +996,23 @@ def create_subgraph(self, requested_product, rules, subject, validate=True):
         return "\n".join(scheduler.rule_subgraph_visualization([type(subject)], requested_product))
 
 
+def test_duplicated_rules() -> None:
+    err = (
+        r"Redeclaring rule pants\.engine\.rules_test\.test_duplicated_rules\.dup_a with "
+        r"<function test_duplicated_rules\.<locals>\.dup_a at .*> at line \d+, previously defined "
+        r"by <function test_duplicated_rules\.<locals>\.dup_a at .*> at line \d+\."
+    )
+    with pytest.raises(DuplicateRuleError, match=err):
+
+        @rule
+        async def dup_a() -> A:
+            return A()
+
+        @rule  # type: ignore[no-redef] # noqa: F811
+        async def dup_a() -> B:  # noqa: F811
+            return B()
+
+
 def test_invalid_rule_helper_name() -> None:
     with pytest.raises(ValueError, match="must be private"):
 
diff --git a/src/python/pants/help/help_info_extracter_test.py b/src/python/pants/help/help_info_extracter_test.py
--- a/src/python/pants/help/help_info_extracter_test.py
+++ b/src/python/pants/help/help_info_extracter_test.py
@@ -401,7 +401,7 @@ def fake_consumed_scopes_mapper(scope: str) -> Tuple[str, ...]:
                     "help": "This rule is for testing info extraction only.",
                     "input_gets": (),
                     "input_types": ("Foo",),
-                    "name": "pants.help.help_info_extracter_test.rule_info_test",
+                    "name": "pants.help.help_info_extracter_test.test_get_all_help_info.rule_info_test",
                     "output_desc": (
                         "A Target represents an addressable set of metadata.\n\n    Set the "
                         "`help` class property with a description, which will be used in "