PY-65385 Retain unbound ParamSpecs as-is during type parameter substitution

The original problem with @contextlib.asynccontextmanager was due to a bug
in PyTypeChecker.substitute introduced in the TypeVarTuple support. Namely,
we started to substitute unmatched ParamSpec types with null, effectively
replacing them in a callable signature with a single parameter of type Any.
Then the special logic in PyCallExpressionHelper.mapArguments that treated
unmatched ParamSpecs as "catch-all" varargs stopped working, and we started
to highlight all extra arguments in the substituted callable invocations.

In other words, binding type parameters from decorator targets, e.g.
ParamSpecs or function return types, never worked because we can't resolve
functions passed as decorator arguments in "de-sugared" expression fragments
in the codeAnalysis context, i.e. when we replace

```
@deco
def f(x: int, y: str): ...
```

with `deco(f)` and then try to infer its type in PyDecoratedFunctionTypeProvider,
but we didn't report it thanks to that special-casing of unmatched ParamSpecs
(other type parameters replaced by Any don't trigger such warnings).

Ideally, we should start resolving references in arguments of function calls
in such virtual expression fragments in some stub-safe manner instead of relying
on this fallback logic. In the general case, however, complete stub-safe inference
for decorators is a hard problem because arbitrary expressions can affect types of
their return type, .e.g.

```
def deco(result: T) -> Callable[[Callable[P, Any]], Callable[P, T]]: ...

@deco(arbitrary_call().foo + 42)  # how to handle this without unstubbing?
def f(x: int, y: str): ...
```

GitOrigin-RevId: adeb625611a3ebb7d5db523df00388d619323545
This commit is contained in:
Mikhail Golubev
2024-02-19 11:39:22 +02:00
committed by intellij-monorepo-bot
parent 07cadf2cf2
commit 7320815ad2
5 changed files with 71 additions and 6 deletions

View File

@@ -1106,7 +1106,10 @@ public final class PyTypeChecker {
}
return substitution;
}
else if (type instanceof PyParamSpecType paramSpecType) {
else if (type instanceof PyParamSpecType paramSpecType && paramSpecType.getParameters() == null) {
if (!substitutions.paramSpecs.containsKey(paramSpecType)) {
return paramSpecType;
}
PyParamSpecType substitution = substitutions.paramSpecs.get(paramSpecType);
if (substitution != null && !substitution.equals(paramSpecType) && hasGenerics(substitution, context)) {
return substitute(substitution, substitutions, context);
@@ -1166,9 +1169,9 @@ public final class PyTypeChecker {
for (PyCallableParameter parameter : parameters) {
final var parameterType = parameter.getType(context);
if (parameters.size() == 1 && parameterType instanceof PyParamSpecType) {
final var parameterTypeSubst = substitutions.paramSpecs.get(parameterType);
if (parameterTypeSubst != null && parameterTypeSubst.getParameters() != null) {
substParams = parameterTypeSubst.getParameters();
final var substitution = substitute(parameterType, substitutions, context);
if (substitution instanceof PyParamSpecType paramSpecSubst && paramSpecSubst.getParameters() != null) {
substParams = paramSpecSubst.getParameters();
break;
}
}

View File

@@ -0,0 +1,6 @@
from mod import func
async def main():
async with func(42, "foo"):
pass

View File

@@ -0,0 +1,6 @@
from contextlib import asynccontextmanager
@asynccontextmanager
async def func(x: int, y: str):
yield "foo"

View File

@@ -4441,6 +4441,21 @@ public class PyTypingTest extends PyTestCase {
""");
}
// PY-65385
public void testUnboundParamSpecFromUnresolvedArgumentRetained() {
doTest("(ParamSpec(\"P\")) -> str",
"""
from typing import Callable, Any, ParamSpec
P = ParamSpec("P")
def deco(fn: Callable[P, Any]) -> Callable[P, str]:
return ...
expr = deco(unresolved)
""");
}
private void doTestNoInjectedText(@NotNull String text) {
myFixture.configureByText(PythonFileType.INSTANCE, text);
final InjectedLanguageManager languageManager = InjectedLanguageManager.getInstance(myFixture.getProject());

View File

@@ -1,10 +1,8 @@
// Copyright 2000-2021 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE file.
package com.jetbrains.python.inspections;
import com.intellij.testFramework.LightProjectDescriptor;
import com.jetbrains.python.fixtures.PyInspectionTestCase;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
public class Py3ArgumentListInspectionTest extends PyInspectionTestCase {
@NotNull
@@ -136,4 +134,41 @@ public class Py3ArgumentListInspectionTest extends PyInspectionTestCase {
non_working_function(1.1, 2.2)
""");
}
// PY-70484
public void testParamSpecInDecoratorReturnTypeCannotBeBoundFromArguments() {
doTestByText("""
from typing import Callable, Any, ParamSpec
P = ParamSpec("P")
def deco(fn: Callable[..., Any]) -> Callable[P, Any]:
return fn
@deco
def f(x: int):
pass
f("foo", 42)
""");
}
// PY-70484
public void testParamSpecInDecoratorReturnTypeUnboundDueToUnresolvedArgument() {
doTestByText("""
from typing import Callable, Any, ParamSpec
P = ParamSpec("P")
def deco(fn: Callable[P, Any]) -> Callable[P, Any]:
return fn
deco(unresolved)("foo", 42)
""");
}
// PY-65385
public void testImportedFunctionDecoratedWithAsyncContextManager() {
doMultiFileTest();
}
}