Repository: pylint-dev/pylint

Issue:
E1520 recommends an invalid combination of `@singledispatch` and `@staticmethod`
### Bug description

Hi from Ruff!

We recently received a [bug report](https://github.com/astral-sh/ruff/issues/10619) at Ruff stating that our reimplementation of E1520 was recommending an invalid combination of `@functools.singledispatch` and `@staticmethod`. For example, this code runs fine, but ruff's PLE1520 rule was telling the issue reporter that they should use `@singledispatch` here instead of `@singledispatchmethod`. If you do so, the code raises an exception at runtime, as `@staticmethod` is a descriptor, and `@singledispatch` isn't designed to be stacked on top of descriptor instances (that's what `@singledispatchmethod` is for). The Python docs also state that `@singledispatchmethod` should be used for static methods: https://docs.python.org/3/library/functools.html#functools.singledispatchmethod.

```py
from functools import singledispatch, singledispatchmethod

class Foo:
    @singledispatchmethod
    @staticmethod
    def bar(value):
        raise NotImplementedError() 

    @bar.register
    @staticmethod
    def _(value: int) -> int:
        return 42 * value

    def method(self) -> int:
        return self.bar(2)

print(Foo().method())
```

After investigating the bug, we decided to change our PLE1520 rule so that it no longer complained about `@singledispatchmethod` on top of `@staticmethod`. It looks like pylint has the same incorrect behaviour here in the original PLE1520 rule, though, so I thought I'd open an issue to give you a heads-up as well!

In case it's helpful, here's the fix that was contributed to Ruff to fix the bug: https://github.com/astral-sh/ruff/pull/10637. The fix also involved some small changes to PLE1519 (E1519 at pylint) as well as PLE1520 (E1520 at pylint).


### Command used

```shell
pylint demo.py
```


### Pylint output

```
************* Module demo
demo.py:7:35: C0303: Trailing whitespace (trailing-whitespace)
demo.py:1:0: C0114: Missing module docstring (missing-module-docstring)
demo.py:3:0: C0115: Missing class docstring (missing-class-docstring)
demo.py:6:4: C0116: Missing function or method docstring (missing-function-docstring)
demo.py:6:4: C0104: Disallowed name "bar" (disallowed-name)
demo.py:4:5: E1520: singledispatchmethod decorator should not be used with functions, use singledispatch instead. (singledispatchmethod-function)
demo.py:9:5: E1520: singledispatchmethod decorator should not be used with functions, use singledispatch instead. (singledispatchmethod-function)
demo.py:14:4: C0116: Missing function or method docstring (missing-function-docstring)
demo.py:1:0: W0611: Unused singledispatch imported from functools (unused-import)

------------------------------------------------------------------
Your code has been rated at 0.00/10 (previous run: 0.00/10, +0.00)
```

### Expected behavior

```
************* Module demo
demo.py:7:35: C0303: Trailing whitespace (trailing-whitespace)
demo.py:1:0: C0114: Missing module docstring (missing-module-docstring)
demo.py:3:0: C0115: Missing class docstring (missing-class-docstring)
demo.py:6:4: C0116: Missing function or method docstring (missing-function-docstring)
demo.py:6:4: C0104: Disallowed name "bar" (disallowed-name)
demo.py:14:4: C0116: Missing function or method docstring (missing-function-docstring)
demo.py:1:0: W0611: Unused singledispatch imported from functools (unused-import)

------------------------------------------------------------------
Your code has been rated at 10.00/10 (previous run: 0.00/10, +0.00)
```

### Pylint version

```shell
pylint 3.1.0
astroid 3.1.0
Python 3.12.2 (main, Feb 15 2024, 19:30:27) [Clang 15.0.0 (clang-1500.1.0.2.5)]
```

### OS / Environment

MacOS; Python 3.12.2

### Additional dependencies

None


Discussion:

Patch:
diff --git a/doc/data/messages/s/singledispatch-method/bad.py b/doc/data/messages/s/singledispatch-method/bad.py
index 49e545b92d..27df8d2ba0 100644
--- a/doc/data/messages/s/singledispatch-method/bad.py
+++ b/doc/data/messages/s/singledispatch-method/bad.py
@@ -3,17 +3,14 @@
 
 class Board:
     @singledispatch  # [singledispatch-method]
-    @classmethod
-    def convert_position(cls, position):
+    def convert_position(self, position):
         pass
 
     @convert_position.register  # [singledispatch-method]
-    @classmethod
-    def _(cls, position: str) -> tuple:
+    def _(self, position: str) -> tuple:
         position_a, position_b = position.split(",")
         return (int(position_a), int(position_b))
 
     @convert_position.register  # [singledispatch-method]
-    @classmethod
-    def _(cls, position: tuple) -> str:
+    def _(self, position: tuple) -> str:
         return f"{position[0]},{position[1]}"
diff --git a/doc/data/messages/s/singledispatch-method/good.py b/doc/data/messages/s/singledispatch-method/good.py
index f38047cd13..36e623d1e0 100644
--- a/doc/data/messages/s/singledispatch-method/good.py
+++ b/doc/data/messages/s/singledispatch-method/good.py
@@ -1,19 +1,17 @@
 from functools import singledispatch
 
 
-class Board:
-    @singledispatch
-    @staticmethod
-    def convert_position(position):
-        pass
+@singledispatch
+def convert_position(position):
+    print(position)
 
-    @convert_position.register
-    @staticmethod
-    def _(position: str) -> tuple:
-        position_a, position_b = position.split(",")
-        return (int(position_a), int(position_b))
 
-    @convert_position.register
-    @staticmethod
-    def _(position: tuple) -> str:
-        return f"{position[0]},{position[1]}"
+@convert_position.register
+def _(position: str) -> tuple:
+    position_a, position_b = position.split(",")
+    return (int(position_a), int(position_b))
+
+
+@convert_position.register
+def _(position: tuple) -> str:
+    return f"{position[0]},{position[1]}"
diff --git a/doc/data/messages/s/singledispatchmethod-function/bad.py b/doc/data/messages/s/singledispatchmethod-function/bad.py
index d2255f8659..861d3a20e9 100644
--- a/doc/data/messages/s/singledispatchmethod-function/bad.py
+++ b/doc/data/messages/s/singledispatchmethod-function/bad.py
@@ -1,19 +1,17 @@
 from functools import singledispatchmethod
 
 
-class Board:
-    @singledispatchmethod  # [singledispatchmethod-function]
-    @staticmethod
-    def convert_position(position):
-        pass
+@singledispatchmethod  # [singledispatchmethod-function]
+def convert_position(position):
+    print(position)
 
-    @convert_position.register  # [singledispatchmethod-function]
-    @staticmethod
-    def _(position: str) -> tuple:
-        position_a, position_b = position.split(",")
-        return (int(position_a), int(position_b))
 
-    @convert_position.register  # [singledispatchmethod-function]
-    @staticmethod
-    def _(position: tuple) -> str:
-        return f"{position[0]},{position[1]}"
+@convert_position.register  # [singledispatchmethod-function]
+def _(position: str) -> tuple:
+    position_a, position_b = position.split(",")
+    return (int(position_a), int(position_b))
+
+
+@convert_position.register  # [singledispatchmethod-function]
+def _(position: tuple) -> str:
+    return f"{position[0]},{position[1]}"
diff --git a/doc/whatsnew/fragments/9531.false_positive b/doc/whatsnew/fragments/9531.false_positive
new file mode 100644
index 0000000000..b776628397
--- /dev/null
+++ b/doc/whatsnew/fragments/9531.false_positive
@@ -0,0 +1,3 @@
+Fix a false positive with ``singledispatchmethod-function`` when a method is decorated with both ``functools.singledispatchmethod`` and ``staticmethod``.
+
+Closes #9531
diff --git a/pylint/checkers/stdlib.py b/pylint/checkers/stdlib.py
index df8b271bf7..10c1d54bfc 100644
--- a/pylint/checkers/stdlib.py
+++ b/pylint/checkers/stdlib.py
@@ -673,8 +673,9 @@ def visit_boolop(self, node: nodes.BoolOp) -> None:
         "singledispatchmethod-function",
     )
     def visit_functiondef(self, node: nodes.FunctionDef) -> None:
-        if node.decorators and isinstance(node.parent, nodes.ClassDef):
-            self._check_lru_cache_decorators(node)
+        if node.decorators:
+            if isinstance(node.parent, nodes.ClassDef):
+                self._check_lru_cache_decorators(node)
             self._check_dispatch_decorators(node)
 
     def _check_lru_cache_decorators(self, node: nodes.FunctionDef) -> None:
@@ -733,16 +734,14 @@ def _check_dispatch_decorators(self, node: nodes.FunctionDef) -> None:
                     interfaces.INFERENCE,
                 )
 
-        if "singledispatch" in decorators_map and "classmethod" in decorators_map:
-            self.add_message(
-                "singledispatch-method",
-                node=decorators_map["singledispatch"][0],
-                confidence=decorators_map["singledispatch"][1],
-            )
-        elif (
-            "singledispatchmethod" in decorators_map
-            and "staticmethod" in decorators_map
-        ):
+        if node.is_method():
+            if "singledispatch" in decorators_map:
+                self.add_message(
+                    "singledispatch-method",
+                    node=decorators_map["singledispatch"][0],
+                    confidence=decorators_map["singledispatch"][1],
+                )
+        elif "singledispatchmethod" in decorators_map:
             self.add_message(
                 "singledispatchmethod-function",
                 node=decorators_map["singledispatchmethod"][0],


Test patch:
diff --git a/tests/functional/s/singledispatch/singledispatch_method.py b/tests/functional/s/singledispatch/singledispatch_method.py
new file mode 100644
index 0000000000..789abc1f84
--- /dev/null
+++ b/tests/functional/s/singledispatch/singledispatch_method.py
@@ -0,0 +1,72 @@
+"""Tests for singledispatch-method"""
+# pylint: disable=missing-class-docstring, missing-function-docstring,too-few-public-methods
+
+
+from functools import singledispatch
+
+
+class Board1:
+    @singledispatch  # [singledispatch-method]
+    def convert_position(self, position):
+        pass
+
+    @convert_position.register  # [singledispatch-method]
+    def _(self, position: str) -> tuple:
+        position_a, position_b = position.split(",")
+        return (int(position_a), int(position_b))
+
+    @convert_position.register  # [singledispatch-method]
+    def _(self, position: tuple) -> str:
+        return f"{position[0]},{position[1]}"
+
+
+class Board2:
+    @singledispatch  # [singledispatch-method]
+    @classmethod
+    def convert_position(cls, position):
+        pass
+
+    @convert_position.register  # [singledispatch-method]
+    @classmethod
+    def _(cls, position: str) -> tuple:
+        position_a, position_b = position.split(",")
+        return (int(position_a), int(position_b))
+
+    @convert_position.register  # [singledispatch-method]
+    @classmethod
+    def _(cls, position: tuple) -> str:
+        return f"{position[0]},{position[1]}"
+
+
+
+class Board3:
+    @singledispatch  # [singledispatch-method]
+    @staticmethod
+    def convert_position(position):
+        pass
+
+    @convert_position.register  # [singledispatch-method]
+    @staticmethod
+    def _(position: str) -> tuple:
+        position_a, position_b = position.split(",")
+        return (int(position_a), int(position_b))
+
+    @convert_position.register  # [singledispatch-method]
+    @staticmethod
+    def _(position: tuple) -> str:
+        return f"{position[0]},{position[1]}"
+
+
+# Do not emit `singledispatch-method`:
+@singledispatch
+def convert_position(position):
+    print(position)
+
+@convert_position.register
+def _(position: str) -> tuple:
+    position_a, position_b = position.split(",")
+    return (int(position_a), int(position_b))
+
+@convert_position.register
+def _(position: tuple) -> str:
+    return f"{position[0]},{position[1]}"
diff --git a/tests/functional/s/singledispatch/singledispatch_method.txt b/tests/functional/s/singledispatch/singledispatch_method.txt
index c747fb6a84..794355121f 100644
--- a/tests/functional/s/singledispatch/singledispatch_method.txt
+++ b/tests/functional/s/singledispatch/singledispatch_method.txt
@@ -1,3 +1,9 @@
-singledispatch-method:26:5:26:19:Board.convert_position:singledispatch decorator should not be used with methods, use singledispatchmethod instead.:HIGH
-singledispatch-method:31:5:31:30:Board._:singledispatch decorator should not be used with methods, use singledispatchmethod instead.:INFERENCE
-singledispatch-method:37:5:37:30:Board._:singledispatch decorator should not be used with methods, use singledispatchmethod instead.:INFERENCE
+singledispatch-method:9:5:9:19:Board1.convert_position:singledispatch decorator should not be used with methods, use singledispatchmethod instead.:HIGH
+singledispatch-method:13:5:13:30:Board1._:singledispatch decorator should not be used with methods, use singledispatchmethod instead.:INFERENCE
+singledispatch-method:18:5:18:30:Board1._:singledispatch decorator should not be used with methods, use singledispatchmethod instead.:INFERENCE
+singledispatch-method:24:5:24:19:Board2.convert_position:singledispatch decorator should not be used with methods, use singledispatchmethod instead.:HIGH
+singledispatch-method:29:5:29:30:Board2._:singledispatch decorator should not be used with methods, use singledispatchmethod instead.:INFERENCE
+singledispatch-method:35:5:35:30:Board2._:singledispatch decorator should not be used with methods, use singledispatchmethod instead.:INFERENCE
+singledispatch-method:43:5:43:19:Board3.convert_position:singledispatch decorator should not be used with methods, use singledispatchmethod instead.:HIGH
+singledispatch-method:48:5:48:30:Board3._:singledispatch decorator should not be used with methods, use singledispatchmethod instead.:INFERENCE
+singledispatch-method:54:5:54:30:Board3._:singledispatch decorator should not be used with methods, use singledispatchmethod instead.:INFERENCE
diff --git a/tests/functional/s/singledispatch/singledispatch_method_py37.py b/tests/functional/s/singledispatch/singledispatch_method_py37.py
deleted file mode 100644
index c9269f7bf1..0000000000
--- a/tests/functional/s/singledispatch/singledispatch_method_py37.py
+++ /dev/null
@@ -1,23 +0,0 @@
-"""Tests for singledispatch-method"""
-# pylint: disable=missing-class-docstring, missing-function-docstring,too-few-public-methods
-
-
-from functools import singledispatch
-
-
-class Board:
-    @singledispatch  # [singledispatch-method]
-    @classmethod
-    def convert_position(cls, position):
-        pass
-
-    @convert_position.register  # [singledispatch-method]
-    @classmethod
-    def _(cls, position: str) -> tuple:
-        position_a, position_b = position.split(",")
-        return (int(position_a), int(position_b))
-
-    @convert_position.register  # [singledispatch-method]
-    @classmethod
-    def _(cls, position: tuple) -> str:
-        return f"{position[0]},{position[1]}"
diff --git a/tests/functional/s/singledispatch/singledispatch_method_py37.rc b/tests/functional/s/singledispatch/singledispatch_method_py37.rc
deleted file mode 100644
index 77eb3be645..0000000000
--- a/tests/functional/s/singledispatch/singledispatch_method_py37.rc
+++ /dev/null
@@ -1,2 +0,0 @@
-[main]
-py-version=3.7
diff --git a/tests/functional/s/singledispatch/singledispatch_method_py37.txt b/tests/functional/s/singledispatch/singledispatch_method_py37.txt
deleted file mode 100644
index 111bc47225..0000000000
--- a/tests/functional/s/singledispatch/singledispatch_method_py37.txt
+++ /dev/null
@@ -1,3 +0,0 @@
-singledispatch-method:9:5:9:19:Board.convert_position:singledispatch decorator should not be used with methods, use singledispatchmethod instead.:HIGH
-singledispatch-method:14:5:14:30:Board._:singledispatch decorator should not be used with methods, use singledispatchmethod instead.:INFERENCE
-singledispatch-method:20:5:20:30:Board._:singledispatch decorator should not be used with methods, use singledispatchmethod instead.:INFERENCE
diff --git a/tests/functional/s/singledispatch/singledispatch_method_py38.py b/tests/functional/s/singledispatch/singledispatch_method_py38.py
deleted file mode 100644
index ad8eea1dd8..0000000000
--- a/tests/functional/s/singledispatch/singledispatch_method_py38.py
+++ /dev/null
@@ -1,40 +0,0 @@
-"""Tests for singledispatch-method"""
-# pylint: disable=missing-class-docstring, missing-function-docstring,too-few-public-methods
-
-
-from functools import singledispatch, singledispatchmethod
-
-
-class BoardRight:
-    @singledispatchmethod
-    @classmethod
-    def convert_position(cls, position):
-        pass
-
-    @convert_position.register
-    @classmethod
-    def _(cls, position: str) -> tuple:
-        position_a, position_b = position.split(",")
-        return (int(position_a), int(position_b))
-
-    @convert_position.register
-    def _(self, position: tuple) -> str:
-        return f"{position[0]},{position[1]}"
-
-
-class Board:
-    @singledispatch  # [singledispatch-method]
-    @classmethod
-    def convert_position(cls, position):
-        pass
-
-    @convert_position.register  # [singledispatch-method]
-    @classmethod
-    def _(cls, position: str) -> tuple:
-        position_a, position_b = position.split(",")
-        return (int(position_a), int(position_b))
-
-    @convert_position.register  # [singledispatch-method]
-    @classmethod
-    def _(cls, position: tuple) -> str:
-        return f"{position[0]},{position[1]}"
diff --git a/tests/functional/s/singledispatch/singledispatch_method_py38.txt b/tests/functional/s/singledispatch/singledispatch_method_py38.txt
deleted file mode 100644
index c747fb6a84..0000000000
--- a/tests/functional/s/singledispatch/singledispatch_method_py38.txt
+++ /dev/null
@@ -1,3 +0,0 @@
-singledispatch-method:26:5:26:19:Board.convert_position:singledispatch decorator should not be used with methods, use singledispatchmethod instead.:HIGH
-singledispatch-method:31:5:31:30:Board._:singledispatch decorator should not be used with methods, use singledispatchmethod instead.:INFERENCE
-singledispatch-method:37:5:37:30:Board._:singledispatch decorator should not be used with methods, use singledispatchmethod instead.:INFERENCE
diff --git a/tests/functional/s/singledispatch/singledispatchmethod_function.py b/tests/functional/s/singledispatch/singledispatchmethod_function.py
new file mode 100644
index 0000000000..1a3bf8db9b
--- /dev/null
+++ b/tests/functional/s/singledispatch/singledispatchmethod_function.py
@@ -0,0 +1,71 @@
+"""Tests for singledispatchmethod-function"""
+# pylint: disable=missing-class-docstring, missing-function-docstring,too-few-public-methods
+
+
+from functools import singledispatchmethod
+
+
+# Emit `singledispatchmethod-function` when functions are decorated with `singledispatchmethod`
+@singledispatchmethod  # [singledispatchmethod-function]
+def convert_position2(position):
+    print(position)
+
+@convert_position2.register  # [singledispatchmethod-function]
+def _(position: str) -> tuple:
+    position_a, position_b = position.split(",")
+    return (int(position_a), int(position_b))
+
+@convert_position2.register  # [singledispatchmethod-function]
+def _(position: tuple) -> str:
+    return f"{position[0]},{position[1]}"
+
+
+class Board1:
+    @singledispatchmethod
+    def convert_position(self, position):
+        pass
+
+    @convert_position.register
+    def _(self, position: str) -> tuple:
+        position_a, position_b = position.split(",")
+        return (int(position_a), int(position_b))
+
+    @convert_position.register
+    def _(self, position: tuple) -> str:
+        return f"{position[0]},{position[1]}"
+
+
+class Board2:
+    @singledispatchmethod
+    @staticmethod
+    def convert_position(position):
+        pass
+
+    @convert_position.register
+    @staticmethod
+    def _(position: str) -> tuple:
+        position_a, position_b = position.split(",")
+        return (int(position_a), int(position_b))
+
+    @convert_position.register
+    @staticmethod
+    def _(position: tuple) -> str:
+        return f"{position[0]},{position[1]}"
+
+
+class Board3:
+    @singledispatchmethod
+    @classmethod
+    def convert_position(cls, position):
+        pass
+
+    @convert_position.register
+    @classmethod
+    def _(cls, position: str) -> tuple:
+        position_a, position_b = position.split(",")
+        return (int(position_a), int(position_b))
+
+    @convert_position.register
+    @classmethod
+    def _(cls, position: tuple) -> str:
+        return f"{position[0]},{position[1]}"
diff --git a/tests/functional/s/singledispatch/singledispatchmethod_function.txt b/tests/functional/s/singledispatch/singledispatchmethod_function.txt
new file mode 100644
index 0000000000..c25f70cf53
--- /dev/null
+++ b/tests/functional/s/singledispatch/singledispatchmethod_function.txt
@@ -0,0 +1,3 @@
+singledispatchmethod-function:9:1:9:21:convert_position2:singledispatchmethod decorator should not be used with functions, use singledispatch instead.:HIGH
+singledispatchmethod-function:13:1:13:27:_:singledispatchmethod decorator should not be used with functions, use singledispatch instead.:INFERENCE
+singledispatchmethod-function:18:1:18:27:_:singledispatchmethod decorator should not be used with functions, use singledispatch instead.:INFERENCE
diff --git a/tests/functional/s/singledispatch/singledispatchmethod_function_py38.py b/tests/functional/s/singledispatch/singledispatchmethod_function_py38.py
deleted file mode 100644
index ef44f71c15..0000000000
--- a/tests/functional/s/singledispatch/singledispatchmethod_function_py38.py
+++ /dev/null
@@ -1,41 +0,0 @@
-"""Tests for singledispatchmethod-function"""
-# pylint: disable=missing-class-docstring, missing-function-docstring,too-few-public-methods
-
-
-from functools import singledispatch, singledispatchmethod
-
-
-class BoardRight:
-    @singledispatch
-    @staticmethod
-    def convert_position(position):
-        pass
-
-    @convert_position.register
-    @staticmethod
-    def _(position: str) -> tuple:
-        position_a, position_b = position.split(",")
-        return (int(position_a), int(position_b))
-
-    @convert_position.register
-    @staticmethod
-    def _(position: tuple) -> str:
-        return f"{position[0]},{position[1]}"
-
-
-class Board:
-    @singledispatchmethod  # [singledispatchmethod-function]
-    @staticmethod
-    def convert_position(position):
-        pass
-
-    @convert_position.register  # [singledispatchmethod-function]
-    @staticmethod
-    def _(position: str) -> tuple:
-        position_a, position_b = position.split(",")
-        return (int(position_a), int(position_b))
-
-    @convert_position.register  # [singledispatchmethod-function]
-    @staticmethod
-    def _(position: tuple) -> str:
-        return f"{position[0]},{position[1]}"
diff --git a/tests/functional/s/singledispatch/singledispatchmethod_function_py38.txt b/tests/functional/s/singledispatch/singledispatchmethod_function_py38.txt
deleted file mode 100644
index 4c236b3466..0000000000
--- a/tests/functional/s/singledispatch/singledispatchmethod_function_py38.txt
+++ /dev/null
@@ -1,3 +0,0 @@
-singledispatchmethod-function:27:5:27:25:Board.convert_position:singledispatchmethod decorator should not be used with functions, use singledispatch instead.:HIGH
-singledispatchmethod-function:32:5:32:30:Board._:singledispatchmethod decorator should not be used with functions, use singledispatch instead.:INFERENCE
-singledispatchmethod-function:38:5:38:30:Board._:singledispatchmethod decorator should not be used with functions, use singledispatch instead.:INFERENCE