mirror of
https://github.com/astral-sh/ruff.git
synced 2025-11-18 19:41:34 +00:00
[ty] Understand legacy and PEP 695 ParamSpec (#21139)
## Summary This PR adds support for understanding the legacy definition and PEP 695 definition for `ParamSpec`. This is still very initial and doesn't really implement any of the semantics. Part of https://github.com/astral-sh/ty/issues/157 ## Test Plan Add mdtest cases. ## Ecosystem analysis Most of the diagnostics in `starlette` are due to the fact that ty now understands `ParamSpec` is not a `Todo` type, so the assignability check fails. The code looks something like: ```py class _MiddlewareFactory(Protocol[P]): def __call__(self, app: ASGIApp, /, *args: P.args, **kwargs: P.kwargs) -> ASGIApp: ... # pragma: no cover class Middleware: def __init__( self, cls: _MiddlewareFactory[P], *args: P.args, **kwargs: P.kwargs, ) -> None: self.cls = cls self.args = args self.kwargs = kwargs # ty complains that `ServerErrorMiddleware` is not assignable to `_MiddlewareFactory[P]` Middleware(ServerErrorMiddleware, handler=error_handler, debug=debug) ``` There are multiple diagnostics where there's an attribute access on the `Wrapped` object of `functools` which Pyright also raises: ```py from functools import wraps def my_decorator(f): @wraps(f) def wrapper(*args, **kwds): return f(*args, **kwds) # Pyright: Cannot access attribute "__signature__" for class "_Wrapped[..., Unknown, ..., Unknown]" Attribute "__signature__" is unknown [reportAttributeAccessIssue] # ty: Object of type `_Wrapped[Unknown, Unknown, Unknown, Unknown]` has no attribute `__signature__` [unresolved-attribute] wrapper.__signature__ return wrapper ``` There are additional diagnostics that is due to the assignability checks failing because ty now infers the `ParamSpec` instead of using the `Todo` type which would always succeed. This results in a few `no-matching-overload` diagnostics because the assignability checks fail. There are a few diagnostics related to https://github.com/astral-sh/ty/issues/491 where there's a variable which is either a bound method or a variable that's annotated with `Callable` that doesn't contain the instance as the first parameter. Another set of (valid) diagnostics are where the code hasn't provided all the type variables. ty is now raising diagnostics for these because we include `ParamSpec` type variable in the signature. For example, `staticmethod[Any]` which contains two type variables.
This commit is contained in:
parent
132d10fb6f
commit
cb2e277482
16 changed files with 684 additions and 146 deletions
|
|
@ -307,8 +307,9 @@ Using a `ParamSpec` in a `Callable` annotation:
|
|||
from typing_extensions import Callable
|
||||
|
||||
def _[**P1](c: Callable[P1, int]):
|
||||
reveal_type(P1.args) # revealed: @Todo(ParamSpec)
|
||||
reveal_type(P1.kwargs) # revealed: @Todo(ParamSpec)
|
||||
# TODO: Should reveal `ParamSpecArgs` and `ParamSpecKwargs`
|
||||
reveal_type(P1.args) # revealed: @Todo(ParamSpecArgs / ParamSpecKwargs)
|
||||
reveal_type(P1.kwargs) # revealed: @Todo(ParamSpecArgs / ParamSpecKwargs)
|
||||
|
||||
# TODO: Signature should be (**P1) -> int
|
||||
reveal_type(c) # revealed: (...) -> int
|
||||
|
|
|
|||
|
|
@ -21,8 +21,9 @@ def f(*args: Unpack[Ts]) -> tuple[Unpack[Ts]]:
|
|||
|
||||
def g() -> TypeGuard[int]: ...
|
||||
def i(callback: Callable[Concatenate[int, P], R_co], *args: P.args, **kwargs: P.kwargs) -> R_co:
|
||||
reveal_type(args) # revealed: tuple[@Todo(Support for `typing.ParamSpec`), ...]
|
||||
reveal_type(kwargs) # revealed: dict[str, @Todo(Support for `typing.ParamSpec`)]
|
||||
# TODO: Should reveal a type representing `P.args` and `P.kwargs`
|
||||
reveal_type(args) # revealed: tuple[@Todo(ParamSpecArgs / ParamSpecKwargs), ...]
|
||||
reveal_type(kwargs) # revealed: dict[str, @Todo(ParamSpecArgs / ParamSpecKwargs)]
|
||||
return callback(42, *args, **kwargs)
|
||||
|
||||
class Foo:
|
||||
|
|
|
|||
|
|
@ -26,9 +26,12 @@ reveal_type(generic_context(SingleTypevar))
|
|||
# revealed: tuple[T@MultipleTypevars, S@MultipleTypevars]
|
||||
reveal_type(generic_context(MultipleTypevars))
|
||||
|
||||
# TODO: support `ParamSpec`/`TypeVarTuple` properly (these should not reveal `None`)
|
||||
reveal_type(generic_context(SingleParamSpec)) # revealed: None
|
||||
reveal_type(generic_context(TypeVarAndParamSpec)) # revealed: None
|
||||
# revealed: tuple[P@SingleParamSpec]
|
||||
reveal_type(generic_context(SingleParamSpec))
|
||||
# revealed: tuple[P@TypeVarAndParamSpec, T@TypeVarAndParamSpec]
|
||||
reveal_type(generic_context(TypeVarAndParamSpec))
|
||||
|
||||
# TODO: support `TypeVarTuple` properly (these should not reveal `None`)
|
||||
reveal_type(generic_context(SingleTypeVarTuple)) # revealed: None
|
||||
reveal_type(generic_context(TypeVarAndTypeVarTuple)) # revealed: None
|
||||
```
|
||||
|
|
|
|||
159
crates/ty_python_semantic/resources/mdtest/paramspec.md
Normal file
159
crates/ty_python_semantic/resources/mdtest/paramspec.md
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
# `ParamSpec`
|
||||
|
||||
## Definition
|
||||
|
||||
### Valid
|
||||
|
||||
```py
|
||||
from typing import ParamSpec
|
||||
|
||||
P = ParamSpec("P")
|
||||
reveal_type(type(P)) # revealed: <class 'ParamSpec'>
|
||||
reveal_type(P) # revealed: typing.ParamSpec
|
||||
reveal_type(P.__name__) # revealed: Literal["P"]
|
||||
```
|
||||
|
||||
The paramspec name can also be provided as a keyword argument:
|
||||
|
||||
```py
|
||||
from typing import ParamSpec
|
||||
|
||||
P = ParamSpec(name="P")
|
||||
reveal_type(P.__name__) # revealed: Literal["P"]
|
||||
```
|
||||
|
||||
### Must be directly assigned to a variable
|
||||
|
||||
```py
|
||||
from typing import ParamSpec
|
||||
|
||||
P = ParamSpec("P")
|
||||
# error: [invalid-paramspec]
|
||||
P1: ParamSpec = ParamSpec("P1")
|
||||
|
||||
# error: [invalid-paramspec]
|
||||
tuple_with_typevar = ("foo", ParamSpec("W"))
|
||||
reveal_type(tuple_with_typevar[1]) # revealed: ParamSpec
|
||||
```
|
||||
|
||||
```py
|
||||
from typing_extensions import ParamSpec
|
||||
|
||||
T = ParamSpec("T")
|
||||
# error: [invalid-paramspec]
|
||||
P1: ParamSpec = ParamSpec("P1")
|
||||
|
||||
# error: [invalid-paramspec]
|
||||
tuple_with_typevar = ("foo", ParamSpec("P2"))
|
||||
reveal_type(tuple_with_typevar[1]) # revealed: ParamSpec
|
||||
```
|
||||
|
||||
### `ParamSpec` parameter must match variable name
|
||||
|
||||
```py
|
||||
from typing import ParamSpec
|
||||
|
||||
P1 = ParamSpec("P1")
|
||||
|
||||
# error: [invalid-paramspec]
|
||||
P2 = ParamSpec("P3")
|
||||
```
|
||||
|
||||
### Accepts only a single `name` argument
|
||||
|
||||
> The runtime should accept bounds and covariant and contravariant arguments in the declaration just
|
||||
> as typing.TypeVar does, but for now we will defer the standardization of the semantics of those
|
||||
> options to a later PEP.
|
||||
|
||||
```py
|
||||
from typing import ParamSpec
|
||||
|
||||
# error: [invalid-paramspec]
|
||||
P1 = ParamSpec("P1", bound=int)
|
||||
# error: [invalid-paramspec]
|
||||
P2 = ParamSpec("P2", int, str)
|
||||
# error: [invalid-paramspec]
|
||||
P3 = ParamSpec("P3", covariant=True)
|
||||
# error: [invalid-paramspec]
|
||||
P4 = ParamSpec("P4", contravariant=True)
|
||||
```
|
||||
|
||||
### Defaults
|
||||
|
||||
```toml
|
||||
[environment]
|
||||
python-version = "3.13"
|
||||
```
|
||||
|
||||
The default value for a `ParamSpec` can be either a list of types, `...`, or another `ParamSpec`.
|
||||
|
||||
```py
|
||||
from typing import ParamSpec
|
||||
|
||||
P1 = ParamSpec("P1", default=[int, str])
|
||||
P2 = ParamSpec("P2", default=...)
|
||||
P3 = ParamSpec("P3", default=P2)
|
||||
```
|
||||
|
||||
Other values are invalid.
|
||||
|
||||
```py
|
||||
# error: [invalid-paramspec]
|
||||
P4 = ParamSpec("P4", default=int)
|
||||
```
|
||||
|
||||
### PEP 695
|
||||
|
||||
```toml
|
||||
[environment]
|
||||
python-version = "3.12"
|
||||
```
|
||||
|
||||
#### Valid
|
||||
|
||||
```py
|
||||
def foo1[**P]() -> None:
|
||||
reveal_type(P) # revealed: typing.ParamSpec
|
||||
|
||||
def foo2[**P = ...]() -> None:
|
||||
reveal_type(P) # revealed: typing.ParamSpec
|
||||
|
||||
def foo3[**P = [int, str]]() -> None:
|
||||
reveal_type(P) # revealed: typing.ParamSpec
|
||||
|
||||
def foo4[**P, **Q = P]():
|
||||
reveal_type(P) # revealed: typing.ParamSpec
|
||||
reveal_type(Q) # revealed: typing.ParamSpec
|
||||
```
|
||||
|
||||
#### Invalid
|
||||
|
||||
ParamSpec, when defined using the new syntax, does not allow defining bounds or constraints.
|
||||
|
||||
This results in a lot of syntax errors mainly because the AST doesn't accept them in this position.
|
||||
The parser could do a better job in recovering from these errors.
|
||||
|
||||
<!-- blacken-docs:off -->
|
||||
|
||||
```py
|
||||
# error: [invalid-syntax]
|
||||
# error: [invalid-syntax]
|
||||
# error: [invalid-syntax]
|
||||
# error: [invalid-syntax]
|
||||
# error: [invalid-syntax]
|
||||
# error: [invalid-syntax]
|
||||
def foo[**P: int]() -> None:
|
||||
# error: [invalid-syntax]
|
||||
# error: [invalid-syntax]
|
||||
pass
|
||||
```
|
||||
|
||||
<!-- blacken-docs:on -->
|
||||
|
||||
#### Invalid default
|
||||
|
||||
```py
|
||||
# error: [invalid-paramspec]
|
||||
def foo[**P = int]() -> None:
|
||||
pass
|
||||
```
|
||||
|
|
@ -1171,9 +1171,7 @@ class EggsLegacy(Generic[T, P]): ...
|
|||
static_assert(not is_assignable_to(Spam, Callable[..., Any]))
|
||||
static_assert(not is_assignable_to(SpamLegacy, Callable[..., Any]))
|
||||
static_assert(not is_assignable_to(Eggs, Callable[..., Any]))
|
||||
|
||||
# TODO: should pass
|
||||
static_assert(not is_assignable_to(EggsLegacy, Callable[..., Any])) # error: [static-assert-error]
|
||||
static_assert(not is_assignable_to(EggsLegacy, Callable[..., Any]))
|
||||
```
|
||||
|
||||
### Classes with `__call__` as attribute
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue