Repository: python/mypy (Python)

Issue:
False postives when decorating functions with signature that uses ParamSpec within a decoarator
<!--
  If you're new to mypy and you're not sure whether what you're experiencing is a mypy bug, please see the "Question and Help" form
  instead.
  Please also consider:

  - checking our common issues page: https://mypy.readthedocs.io/en/stable/common_issues.html
  - searching our issue tracker: https://github.com/python/mypy/issues to see if
    it's already been reported
  - asking on gitter chat: https://gitter.im/python/typing
-->

**Bug Report**

<!--
Note: If the problem you are reporting is about a specific library function, then the typeshed tracker is better suited
for this report: https://github.com/python/typeshed/issues
-->

When using a decorator on the decorated function within the definition of another decorator, mypy reports false positives.

**To Reproduce**

```python3
from typing import Callable, TypeVar
from typing_extensions import ParamSpec, Concatenate

P = ParamSpec("P")
T = TypeVar("T")


def identity(func: Callable[P, T]) -> Callable[P, T]:
    def _wrapped(*args: P.args, **kwargs: P.kwargs) -> T:
        return func(*args, **kwargs)

    return _wrapped


def int_identity(func: Callable[Concatenate[int, P], T]) -> Callable[Concatenate[int, P], T]:
    # identify should preserve the signature of _wrapped
    # error: Argument 1 to "identity" has incompatible type "Callable[[Arg(int, 'number'), **P], T]"; expected "Callable[[Arg(int, 'number'), **P], T]"
    @identity
    def _wrapped(number: int, *args: P.args, **kwargs: P.kwargs) -> T:
        return func(number, *args, **kwargs)

    return _wrapped
```

**Expected Behavior**

<!--
  How did you expect your project to behave?
  It’s fine if you’re not sure your understanding is correct.
  Write down what you thought would happen. If you just expected no errors, you can delete this section.
-->

The code should pass type-check with no errors.

**Actual Behavior**

<!--
  Did something go wrong?
  Is something broken, or not behaving as you expected?
-->

Got error:
`error: Argument 1 to "identity" has incompatible type "Callable[[Arg(int, 'number'), **P], T]"; expected "Callable[[Arg(int, 'number'), 
**P], T]"`

**Your Environment**

<!-- Include as many relevant details about the environment you experienced the bug in -->

- Mypy version used: 0.960
- Mypy command-line flags: `--strict`
- Python version used: Python 3.8.10
- Operating system and version: WSL2 - Ubuntu 20.04


Hints:
I think #12806 is a duplicate of this. And I have an example of my own to contribute:

```python3
from typing import ParamSpec, Callable, TypeVar
from functools import lru_cache, _lru_cache_wrapper

P = ParamSpec("P")
R = TypeVar("R")


def memoize(f: Callable[P, R]) -> "_lru_cache_wrapper[R]":
    return lru_cache(None)(f)


def function(f: Callable[P, R]) -> None:
    # error: Argument 1 to "memoize" has incompatible type "Callable[[Arg(int, 'a'), **P], None]"; expected "Callable[[Arg(int, 'a'), **P], None]"
    @memoize
    def inner_function(a: int, *args: P.args, **kwargs: P.kwargs) -> None:
        ...
    ...
```

The "incompatible types" are stringified exactly the same.

Patch:
diff --git a/mypy/constraints.py b/mypy/constraints.py
--- a/mypy/constraints.py
+++ b/mypy/constraints.py
@@ -949,7 +949,7 @@ def visit_callable_type(self, template: CallableType) -> list[Constraint]:
                 )
 
                 # TODO: see above "FIX" comments for param_spec is None case
-                # TODO: this assume positional arguments
+                # TODO: this assumes positional arguments
                 for t, a in zip(prefix.arg_types, cactual_prefix.arg_types):
                     res.extend(infer_constraints(t, a, neg_op(self.direction)))
 
diff --git a/mypy/expandtype.py b/mypy/expandtype.py
--- a/mypy/expandtype.py
+++ b/mypy/expandtype.py
@@ -234,7 +234,10 @@ def visit_type_var(self, t: TypeVarType) -> Type:
         return repl
 
     def visit_param_spec(self, t: ParamSpecType) -> Type:
-        repl = get_proper_type(self.variables.get(t.id, t))
+        # set prefix to something empty so we don't duplicate it
+        repl = get_proper_type(
+            self.variables.get(t.id, t.copy_modified(prefix=Parameters([], [], [])))
+        )
         if isinstance(repl, Instance):
             # TODO: what does prefix mean in this case?
             # TODO: why does this case even happen? Instances aren't plural.
@@ -357,7 +360,7 @@ def visit_callable_type(self, t: CallableType) -> Type:
             # must expand both of them with all the argument types,
             # kinds and names in the replacement. The return type in
             # the replacement is ignored.
-            if isinstance(repl, CallableType) or isinstance(repl, Parameters):
+            if isinstance(repl, (CallableType, Parameters)):
                 # Substitute *args: P.args, **kwargs: P.kwargs
                 prefix = param_spec.prefix
                 # we need to expand the types in the prefix, so might as well
@@ -370,6 +373,23 @@ def visit_callable_type(self, t: CallableType) -> Type:
                     ret_type=t.ret_type.accept(self),
                     type_guard=(t.type_guard.accept(self) if t.type_guard is not None else None),
                 )
+            # TODO: Conceptually, the "len(t.arg_types) == 2" should not be here. However, this
+            #       errors without it. Either figure out how to eliminate this or place an
+            #       explanation for why this is necessary.
+            elif isinstance(repl, ParamSpecType) and len(t.arg_types) == 2:
+                # We're substituting one paramspec for another; this can mean that the prefix
+                # changes. (e.g. sub Concatenate[int, P] for Q)
+                prefix = repl.prefix
+                old_prefix = param_spec.prefix
+
+                # Check assumptions. I'm not sure what order to place new prefix vs old prefix:
+                assert not old_prefix.arg_types or not prefix.arg_types
+
+                t = t.copy_modified(
+                    arg_types=prefix.arg_types + old_prefix.arg_types + t.arg_types,
+                    arg_kinds=prefix.arg_kinds + old_prefix.arg_kinds + t.arg_kinds,
+                    arg_names=prefix.arg_names + old_prefix.arg_names + t.arg_names,
+                )
 
         var_arg = t.var_arg()
         if var_arg is not None and isinstance(var_arg.typ, UnpackType):
diff --git a/mypy/subtypes.py b/mypy/subtypes.py
--- a/mypy/subtypes.py
+++ b/mypy/subtypes.py
@@ -388,8 +388,7 @@ def _is_subtype(self, left: Type, right: Type) -> bool:
             return is_proper_subtype(left, right, subtype_context=self.subtype_context)
         return is_subtype(left, right, subtype_context=self.subtype_context)
 
-    # visit_x(left) means: is left (which is an instance of X) a subtype of
-    # right?
+    # visit_x(left) means: is left (which is an instance of X) a subtype of right?
 
     def visit_unbound_type(self, left: UnboundType) -> bool:
         # This can be called if there is a bad type annotation. The result probably
diff --git a/mypy/types.py b/mypy/types.py
--- a/mypy/types.py
+++ b/mypy/types.py
@@ -183,7 +183,7 @@ class TypeOfAny:
     # Does this Any come from an error?
     from_error: Final = 5
     # Is this a type that can't be represented in mypy's type system? For instance, type of
-    # call to NewType...). Even though these types aren't real Anys, we treat them as such.
+    # call to NewType(...). Even though these types aren't real Anys, we treat them as such.
     # Also used for variables named '_'.
     special_form: Final = 6
     # Does this Any come from interaction with another Any?
@@ -1972,20 +1972,15 @@ def param_spec(self) -> ParamSpecType | None:
         arg_type = self.arg_types[-2]
         if not isinstance(arg_type, ParamSpecType):
             return None
+
         # sometimes paramspectypes are analyzed in from mysterious places,
         # e.g. def f(prefix..., *args: P.args, **kwargs: P.kwargs) -> ...: ...
         prefix = arg_type.prefix
         if not prefix.arg_types:
             # TODO: confirm that all arg kinds are positional
             prefix = Parameters(self.arg_types[:-2], self.arg_kinds[:-2], self.arg_names[:-2])
-        return ParamSpecType(
-            arg_type.name,
-            arg_type.fullname,
-            arg_type.id,
-            ParamSpecFlavor.BARE,
-            arg_type.upper_bound,
-            prefix=prefix,
-        )
+
+        return arg_type.copy_modified(flavor=ParamSpecFlavor.BARE, prefix=prefix)
 
     def expand_param_spec(
         self, c: CallableType | Parameters, no_prefix: bool = False


Test patch:
diff --git a/test-data/unit/check-parameter-specification.test b/test-data/unit/check-parameter-specification.test
--- a/test-data/unit/check-parameter-specification.test
+++ b/test-data/unit/check-parameter-specification.test
@@ -1471,3 +1471,53 @@ def test(f: Concat[T, ...]) -> None: ...
 
 class Defer: ...
 [builtins fixtures/paramspec.pyi]
+
+[case testNoParamSpecDoubling]
+# https://github.com/python/mypy/issues/12734
+from typing import Callable, ParamSpec
+from typing_extensions import Concatenate
+
+P = ParamSpec("P")
+Q = ParamSpec("Q")
+
+def foo(f: Callable[P, int]) -> Callable[P, int]:
+    return f
+
+def bar(f: Callable[Concatenate[str, Q], int]) -> Callable[Concatenate[str, Q], int]:
+    return foo(f)
+[builtins fixtures/paramspec.pyi]
+
+[case testAlreadyExpandedCallableWithParamSpecReplacement]
+from typing import Callable, Any, overload
+from typing_extensions import Concatenate, ParamSpec
+
+P = ParamSpec("P")
+
+@overload
+def command() -> Callable[[Callable[Concatenate[object, object, P], object]], None]:  # E: Overloaded function signatures 1 and 2 overlap with incompatible return types
+    ...
+
+@overload
+def command(
+    cls: int = ...,
+) -> Callable[[Callable[Concatenate[object, P], object]], None]:
+    ...
+
+def command(
+    cls: int = 42,
+) -> Any:
+    ...
+[builtins fixtures/paramspec.pyi]
+
+[case testCopiedParamSpecComparison]
+# minimized from https://github.com/python/mypy/issues/12909
+from typing import Callable
+from typing_extensions import ParamSpec
+
+P = ParamSpec("P")
+
+def identity(func: Callable[P, None]) -> Callable[P, None]: ...
+
+@identity
+def f(f: Callable[P, None], *args: P.args, **kwargs: P.kwargs) -> None: ...
+[builtins fixtures/paramspec.pyi]