Skip to content

Commit cb2e277

Browse files
authored
[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 astral-sh/ty#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 astral-sh/ty#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.
1 parent 132d10f commit cb2e277

File tree

16 files changed

+684
-146
lines changed

16 files changed

+684
-146
lines changed

crates/ty/docs/rules.md

Lines changed: 97 additions & 66 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/ty_ide/src/goto_type_definition.rs

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -276,10 +276,20 @@ mod tests {
276276
"#,
277277
);
278278

279-
// TODO: Goto type definition currently doesn't work for type param specs
280-
// because the inference doesn't support them yet.
281-
// This snapshot should show a single target pointing to `T`
282-
assert_snapshot!(test.goto_type_definition(), @"No type definitions found");
279+
assert_snapshot!(test.goto_type_definition(), @r"
280+
info[goto-type-definition]: Type definition
281+
--> main.py:2:14
282+
|
283+
2 | type Alias[**P = [int, str]] = Callable[P, int]
284+
| ^
285+
|
286+
info: Source
287+
--> main.py:2:41
288+
|
289+
2 | type Alias[**P = [int, str]] = Callable[P, int]
290+
| ^
291+
|
292+
");
283293
}
284294

285295
#[test]

crates/ty_ide/src/hover.rs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1633,11 +1633,12 @@ def ab(a: int, *, c: int):
16331633
"#,
16341634
);
16351635

1636+
// TODO: This should be `P@Alias (<variance>)`
16361637
assert_snapshot!(test.hover(), @r"
1637-
@Todo
1638+
typing.ParamSpec
16381639
---------------------------------------------
16391640
```python
1640-
@Todo
1641+
typing.ParamSpec
16411642
```
16421643
---------------------------------------------
16431644
info[hover]: Hovered content is

crates/ty_python_semantic/resources/mdtest/annotations/callable.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -307,8 +307,9 @@ Using a `ParamSpec` in a `Callable` annotation:
307307
from typing_extensions import Callable
308308

309309
def _[**P1](c: Callable[P1, int]):
310-
reveal_type(P1.args) # revealed: @Todo(ParamSpec)
311-
reveal_type(P1.kwargs) # revealed: @Todo(ParamSpec)
310+
# TODO: Should reveal `ParamSpecArgs` and `ParamSpecKwargs`
311+
reveal_type(P1.args) # revealed: @Todo(ParamSpecArgs / ParamSpecKwargs)
312+
reveal_type(P1.kwargs) # revealed: @Todo(ParamSpecArgs / ParamSpecKwargs)
312313

313314
# TODO: Signature should be (**P1) -> int
314315
reveal_type(c) # revealed: (...) -> int

crates/ty_python_semantic/resources/mdtest/annotations/unsupported_special_forms.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,9 @@ def f(*args: Unpack[Ts]) -> tuple[Unpack[Ts]]:
2121

2222
def g() -> TypeGuard[int]: ...
2323
def i(callback: Callable[Concatenate[int, P], R_co], *args: P.args, **kwargs: P.kwargs) -> R_co:
24-
reveal_type(args) # revealed: tuple[@Todo(Support for `typing.ParamSpec`), ...]
25-
reveal_type(kwargs) # revealed: dict[str, @Todo(Support for `typing.ParamSpec`)]
24+
# TODO: Should reveal a type representing `P.args` and `P.kwargs`
25+
reveal_type(args) # revealed: tuple[@Todo(ParamSpecArgs / ParamSpecKwargs), ...]
26+
reveal_type(kwargs) # revealed: dict[str, @Todo(ParamSpecArgs / ParamSpecKwargs)]
2627
return callback(42, *args, **kwargs)
2728

2829
class Foo:

crates/ty_python_semantic/resources/mdtest/generics/legacy/classes.md

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,12 @@ reveal_type(generic_context(SingleTypevar))
2626
# revealed: tuple[T@MultipleTypevars, S@MultipleTypevars]
2727
reveal_type(generic_context(MultipleTypevars))
2828

29-
# TODO: support `ParamSpec`/`TypeVarTuple` properly (these should not reveal `None`)
30-
reveal_type(generic_context(SingleParamSpec)) # revealed: None
31-
reveal_type(generic_context(TypeVarAndParamSpec)) # revealed: None
29+
# revealed: tuple[P@SingleParamSpec]
30+
reveal_type(generic_context(SingleParamSpec))
31+
# revealed: tuple[P@TypeVarAndParamSpec, T@TypeVarAndParamSpec]
32+
reveal_type(generic_context(TypeVarAndParamSpec))
33+
34+
# TODO: support `TypeVarTuple` properly (these should not reveal `None`)
3235
reveal_type(generic_context(SingleTypeVarTuple)) # revealed: None
3336
reveal_type(generic_context(TypeVarAndTypeVarTuple)) # revealed: None
3437
```
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
# `ParamSpec`
2+
3+
## Definition
4+
5+
### Valid
6+
7+
```py
8+
from typing import ParamSpec
9+
10+
P = ParamSpec("P")
11+
reveal_type(type(P)) # revealed: <class 'ParamSpec'>
12+
reveal_type(P) # revealed: typing.ParamSpec
13+
reveal_type(P.__name__) # revealed: Literal["P"]
14+
```
15+
16+
The paramspec name can also be provided as a keyword argument:
17+
18+
```py
19+
from typing import ParamSpec
20+
21+
P = ParamSpec(name="P")
22+
reveal_type(P.__name__) # revealed: Literal["P"]
23+
```
24+
25+
### Must be directly assigned to a variable
26+
27+
```py
28+
from typing import ParamSpec
29+
30+
P = ParamSpec("P")
31+
# error: [invalid-paramspec]
32+
P1: ParamSpec = ParamSpec("P1")
33+
34+
# error: [invalid-paramspec]
35+
tuple_with_typevar = ("foo", ParamSpec("W"))
36+
reveal_type(tuple_with_typevar[1]) # revealed: ParamSpec
37+
```
38+
39+
```py
40+
from typing_extensions import ParamSpec
41+
42+
T = ParamSpec("T")
43+
# error: [invalid-paramspec]
44+
P1: ParamSpec = ParamSpec("P1")
45+
46+
# error: [invalid-paramspec]
47+
tuple_with_typevar = ("foo", ParamSpec("P2"))
48+
reveal_type(tuple_with_typevar[1]) # revealed: ParamSpec
49+
```
50+
51+
### `ParamSpec` parameter must match variable name
52+
53+
```py
54+
from typing import ParamSpec
55+
56+
P1 = ParamSpec("P1")
57+
58+
# error: [invalid-paramspec]
59+
P2 = ParamSpec("P3")
60+
```
61+
62+
### Accepts only a single `name` argument
63+
64+
> The runtime should accept bounds and covariant and contravariant arguments in the declaration just
65+
> as typing.TypeVar does, but for now we will defer the standardization of the semantics of those
66+
> options to a later PEP.
67+
68+
```py
69+
from typing import ParamSpec
70+
71+
# error: [invalid-paramspec]
72+
P1 = ParamSpec("P1", bound=int)
73+
# error: [invalid-paramspec]
74+
P2 = ParamSpec("P2", int, str)
75+
# error: [invalid-paramspec]
76+
P3 = ParamSpec("P3", covariant=True)
77+
# error: [invalid-paramspec]
78+
P4 = ParamSpec("P4", contravariant=True)
79+
```
80+
81+
### Defaults
82+
83+
```toml
84+
[environment]
85+
python-version = "3.13"
86+
```
87+
88+
The default value for a `ParamSpec` can be either a list of types, `...`, or another `ParamSpec`.
89+
90+
```py
91+
from typing import ParamSpec
92+
93+
P1 = ParamSpec("P1", default=[int, str])
94+
P2 = ParamSpec("P2", default=...)
95+
P3 = ParamSpec("P3", default=P2)
96+
```
97+
98+
Other values are invalid.
99+
100+
```py
101+
# error: [invalid-paramspec]
102+
P4 = ParamSpec("P4", default=int)
103+
```
104+
105+
### PEP 695
106+
107+
```toml
108+
[environment]
109+
python-version = "3.12"
110+
```
111+
112+
#### Valid
113+
114+
```py
115+
def foo1[**P]() -> None:
116+
reveal_type(P) # revealed: typing.ParamSpec
117+
118+
def foo2[**P = ...]() -> None:
119+
reveal_type(P) # revealed: typing.ParamSpec
120+
121+
def foo3[**P = [int, str]]() -> None:
122+
reveal_type(P) # revealed: typing.ParamSpec
123+
124+
def foo4[**P, **Q = P]():
125+
reveal_type(P) # revealed: typing.ParamSpec
126+
reveal_type(Q) # revealed: typing.ParamSpec
127+
```
128+
129+
#### Invalid
130+
131+
ParamSpec, when defined using the new syntax, does not allow defining bounds or constraints.
132+
133+
This results in a lot of syntax errors mainly because the AST doesn't accept them in this position.
134+
The parser could do a better job in recovering from these errors.
135+
136+
<!-- blacken-docs:off -->
137+
138+
```py
139+
# error: [invalid-syntax]
140+
# error: [invalid-syntax]
141+
# error: [invalid-syntax]
142+
# error: [invalid-syntax]
143+
# error: [invalid-syntax]
144+
# error: [invalid-syntax]
145+
def foo[**P: int]() -> None:
146+
# error: [invalid-syntax]
147+
# error: [invalid-syntax]
148+
pass
149+
```
150+
151+
<!-- blacken-docs:on -->
152+
153+
#### Invalid default
154+
155+
```py
156+
# error: [invalid-paramspec]
157+
def foo[**P = int]() -> None:
158+
pass
159+
```

crates/ty_python_semantic/resources/mdtest/type_properties/is_assignable_to.md

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1171,9 +1171,7 @@ class EggsLegacy(Generic[T, P]): ...
11711171
static_assert(not is_assignable_to(Spam, Callable[..., Any]))
11721172
static_assert(not is_assignable_to(SpamLegacy, Callable[..., Any]))
11731173
static_assert(not is_assignable_to(Eggs, Callable[..., Any]))
1174-
1175-
# TODO: should pass
1176-
static_assert(not is_assignable_to(EggsLegacy, Callable[..., Any])) # error: [static-assert-error]
1174+
static_assert(not is_assignable_to(EggsLegacy, Callable[..., Any]))
11771175
```
11781176

11791177
### Classes with `__call__` as attribute

crates/ty_python_semantic/src/types.rs

Lines changed: 35 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4358,6 +4358,13 @@ impl<'db> Type<'db> {
43584358
.into()
43594359
}
43604360

4361+
Type::KnownInstance(KnownInstanceType::TypeVar(typevar))
4362+
if typevar.kind(db).is_paramspec()
4363+
&& matches!(name.as_str(), "args" | "kwargs") =>
4364+
{
4365+
Place::bound(todo_type!("ParamSpecArgs / ParamSpecKwargs")).into()
4366+
}
4367+
43614368
Type::NominalInstance(..)
43624369
| Type::ProtocolInstance(..)
43634370
| Type::BooleanLiteral(..)
@@ -7024,7 +7031,7 @@ impl<'db> Type<'db> {
70247031
Type::TypeVar(bound_typevar) => {
70257032
if matches!(
70267033
bound_typevar.typevar(db).kind(db),
7027-
TypeVarKind::Legacy | TypeVarKind::TypingSelf
7034+
TypeVarKind::Legacy | TypeVarKind::TypingSelf | TypeVarKind::ParamSpec
70287035
) && binding_context.is_none_or(|binding_context| {
70297036
bound_typevar.binding_context(db) == BindingContext::Definition(binding_context)
70307037
}) {
@@ -7743,6 +7750,9 @@ impl<'db> KnownInstanceType<'db> {
77437750
fn class(self, db: &'db dyn Db) -> KnownClass {
77447751
match self {
77457752
Self::SubscriptedProtocol(_) | Self::SubscriptedGeneric(_) => KnownClass::SpecialForm,
7753+
Self::TypeVar(typevar_instance) if typevar_instance.kind(db).is_paramspec() => {
7754+
KnownClass::ParamSpec
7755+
}
77467756
Self::TypeVar(_) => KnownClass::TypeVar,
77477757
Self::TypeAliasType(TypeAliasType::PEP695(alias)) if alias.is_specialized(db) => {
77487758
KnownClass::GenericAlias
@@ -7808,7 +7818,13 @@ impl<'db> KnownInstanceType<'db> {
78087818
// This is a legacy `TypeVar` _outside_ of any generic class or function, so we render
78097819
// it as an instance of `typing.TypeVar`. Inside of a generic class or function, we'll
78107820
// have a `Type::TypeVar(_)`, which is rendered as the typevar's name.
7811-
KnownInstanceType::TypeVar(_) => f.write_str("typing.TypeVar"),
7821+
KnownInstanceType::TypeVar(typevar_instance) => {
7822+
if typevar_instance.kind(self.db).is_paramspec() {
7823+
f.write_str("typing.ParamSpec")
7824+
} else {
7825+
f.write_str("typing.TypeVar")
7826+
}
7827+
}
78127828
KnownInstanceType::Deprecated(_) => f.write_str("warnings.deprecated"),
78137829
KnownInstanceType::Field(field) => {
78147830
f.write_str("dataclasses.Field")?;
@@ -7864,9 +7880,6 @@ pub enum DynamicType<'db> {
78647880
///
78657881
/// This variant should be created with the `todo_type!` macro.
78667882
Todo(TodoType),
7867-
/// A special Todo-variant for PEP-695 `ParamSpec` types. A temporary variant to detect and special-
7868-
/// case the handling of these types in `Callable` annotations.
7869-
TodoPEP695ParamSpec,
78707883
/// A special Todo-variant for type aliases declared using `typing.TypeAlias`.
78717884
/// A temporary variant to detect and special-case the handling of these aliases in autocomplete suggestions.
78727885
TodoTypeAlias,
@@ -7894,13 +7907,6 @@ impl std::fmt::Display for DynamicType<'_> {
78947907
// `DynamicType::Todo`'s display should be explicit that is not a valid display of
78957908
// any other type
78967909
DynamicType::Todo(todo) => write!(f, "@Todo{todo}"),
7897-
DynamicType::TodoPEP695ParamSpec => {
7898-
if cfg!(debug_assertions) {
7899-
f.write_str("@Todo(ParamSpec)")
7900-
} else {
7901-
f.write_str("@Todo")
7902-
}
7903-
}
79047910
DynamicType::TodoUnpack => {
79057911
if cfg!(debug_assertions) {
79067912
f.write_str("@Todo(typing.Unpack)")
@@ -8239,12 +8245,20 @@ pub enum TypeVarKind {
82398245
Pep695,
82408246
/// `typing.Self`
82418247
TypingSelf,
8248+
/// `P = ParamSpec("P")`
8249+
ParamSpec,
8250+
/// `def foo[**P]() -> None: ...`
8251+
Pep695ParamSpec,
82428252
}
82438253

82448254
impl TypeVarKind {
82458255
const fn is_self(self) -> bool {
82468256
matches!(self, Self::TypingSelf)
82478257
}
8258+
8259+
const fn is_paramspec(self) -> bool {
8260+
matches!(self, Self::ParamSpec | Self::Pep695ParamSpec)
8261+
}
82488262
}
82498263

82508264
/// The identity of a type variable.
@@ -8597,6 +8611,15 @@ impl<'db> TypeVarInstance<'db> {
85978611
let expr = &call_expr.arguments.find_keyword("default")?.value;
85988612
Some(definition_expression_type(db, definition, expr))
85998613
}
8614+
// PEP 695 ParamSpec
8615+
DefinitionKind::ParamSpec(paramspec) => {
8616+
let paramspec_node = paramspec.node(&module);
8617+
Some(definition_expression_type(
8618+
db,
8619+
definition,
8620+
paramspec_node.default.as_ref()?,
8621+
))
8622+
}
86008623
_ => None,
86018624
}
86028625
}

crates/ty_python_semantic/src/types/class_base.rs

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -49,10 +49,7 @@ impl<'db> ClassBase<'db> {
4949
ClassBase::Dynamic(DynamicType::Any) => "Any",
5050
ClassBase::Dynamic(DynamicType::Unknown) => "Unknown",
5151
ClassBase::Dynamic(
52-
DynamicType::Todo(_)
53-
| DynamicType::TodoPEP695ParamSpec
54-
| DynamicType::TodoTypeAlias
55-
| DynamicType::TodoUnpack,
52+
DynamicType::Todo(_) | DynamicType::TodoTypeAlias | DynamicType::TodoUnpack,
5653
) => "@Todo",
5754
ClassBase::Dynamic(DynamicType::Divergent(_)) => "Divergent",
5855
ClassBase::Protocol => "Protocol",

0 commit comments

Comments
 (0)