Skip to content

Commit 031db5d

Browse files
committed
[ty] Understand legacy and PEP 695 ParamSpec
1 parent 1b0ee46 commit 031db5d

File tree

9 files changed

+445
-44
lines changed

9 files changed

+445
-44
lines changed

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: Unknown
312+
reveal_type(P1.kwargs) # revealed: Unknown
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[Unknown, ...]
26+
reveal_type(kwargs) # revealed: dict[str, Unknown]
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: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
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+
### `TypeVar` 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+
```py
89+
from typing import ParamSpec
90+
91+
# TODO: This is not an error
92+
# error: [invalid-type-form]
93+
P = ParamSpec("P", default=[int, str])
94+
```
95+
96+
### PEP 695
97+
98+
```toml
99+
[environment]
100+
python-version = "3.12"
101+
```
102+
103+
#### Valid
104+
105+
```py
106+
def foo1[**P]() -> None:
107+
reveal_type(P) # revealed: typing.ParamSpec
108+
109+
def foo2[**P = ...]() -> None:
110+
reveal_type(P) # revealed: typing.ParamSpec
111+
```
112+
113+
#### Invalid
114+
115+
ParamSpec, when defined using the new syntax, does not allow defining bounds or constraints.
116+
117+
This results in a lot of syntax errors mainly because the AST doesn't accept them in this position.
118+
We could do a better job in recovering from these errors.
119+
120+
```py
121+
# error: [invalid-syntax]
122+
# error: [invalid-syntax]
123+
# error: [invalid-syntax]
124+
# error: [invalid-syntax]
125+
# error: [invalid-syntax]
126+
# error: [invalid-syntax]
127+
def foo[**P: int]() -> None:
128+
# error: [invalid-syntax]
129+
# error: [invalid-syntax]
130+
pass
131+
```

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: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7633,6 +7633,9 @@ impl<'db> KnownInstanceType<'db> {
76337633
fn class(self, db: &'db dyn Db) -> KnownClass {
76347634
match self {
76357635
Self::SubscriptedProtocol(_) | Self::SubscriptedGeneric(_) => KnownClass::SpecialForm,
7636+
Self::TypeVar(typevar_instance) if typevar_instance.kind(db).is_paramspec() => {
7637+
KnownClass::ParamSpec
7638+
}
76367639
Self::TypeVar(_) => KnownClass::TypeVar,
76377640
Self::TypeAliasType(TypeAliasType::PEP695(alias)) if alias.is_specialized(db) => {
76387641
KnownClass::GenericAlias
@@ -7697,7 +7700,13 @@ impl<'db> KnownInstanceType<'db> {
76977700
// This is a legacy `TypeVar` _outside_ of any generic class or function, so we render
76987701
// it as an instance of `typing.TypeVar`. Inside of a generic class or function, we'll
76997702
// have a `Type::TypeVar(_)`, which is rendered as the typevar's name.
7700-
KnownInstanceType::TypeVar(_) => f.write_str("typing.TypeVar"),
7703+
KnownInstanceType::TypeVar(typevar_instance) => {
7704+
if typevar_instance.kind(self.db).is_paramspec() {
7705+
f.write_str("typing.ParamSpec")
7706+
} else {
7707+
f.write_str("typing.TypeVar")
7708+
}
7709+
}
77017710
KnownInstanceType::Deprecated(_) => f.write_str("warnings.deprecated"),
77027711
KnownInstanceType::Field(field) => {
77037712
f.write_str("dataclasses.Field")?;
@@ -8123,12 +8132,20 @@ pub enum TypeVarKind {
81238132
Pep695,
81248133
/// `typing.Self`
81258134
TypingSelf,
8135+
/// `P = ParamSpec("P")`
8136+
ParamSpec,
8137+
/// `def foo[**P]() -> None: ...`
8138+
Pep695ParamSpec,
81268139
}
81278140

81288141
impl TypeVarKind {
81298142
const fn is_self(self) -> bool {
81308143
matches!(self, Self::TypingSelf)
81318144
}
8145+
8146+
const fn is_paramspec(self) -> bool {
8147+
matches!(self, Self::ParamSpec | Self::Pep695ParamSpec)
8148+
}
81328149
}
81338150

81348151
/// The identity of a type variable.
@@ -8481,6 +8498,15 @@ impl<'db> TypeVarInstance<'db> {
84818498
let expr = &call_expr.arguments.find_keyword("default")?.value;
84828499
Some(definition_expression_type(db, definition, expr))
84838500
}
8501+
// PEP 695 ParamSpec
8502+
DefinitionKind::ParamSpec(paramspec) => {
8503+
let paramspec_node = paramspec.node(&module);
8504+
Some(definition_expression_type(
8505+
db,
8506+
definition,
8507+
paramspec_node.default.as_ref()?,
8508+
))
8509+
}
84848510
_ => None,
84858511
}
84868512
}

crates/ty_python_semantic/src/types/diagnostic.rs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ pub(crate) fn register_lints(registry: &mut LintRegistryBuilder) {
6363
registry.register_lint(&INVALID_EXCEPTION_CAUGHT);
6464
registry.register_lint(&INVALID_GENERIC_CLASS);
6565
registry.register_lint(&INVALID_LEGACY_TYPE_VARIABLE);
66+
registry.register_lint(&INVALID_PARAMSPEC);
6667
registry.register_lint(&INVALID_TYPE_ALIAS_TYPE);
6768
registry.register_lint(&INVALID_METACLASS);
6869
registry.register_lint(&INVALID_OVERLOAD);
@@ -872,6 +873,27 @@ declare_lint! {
872873
}
873874
}
874875

876+
declare_lint! {
877+
/// ## What it does
878+
/// Checks for the creation of invalid `ParamSpec`s
879+
///
880+
/// ## Why is this bad?
881+
/// There are several requirements that you must follow when creating a `ParamSpec`.
882+
///
883+
/// ## Examples
884+
/// ```python
885+
/// TODO
886+
/// ```
887+
///
888+
/// ## References
889+
/// - [Typing spec: ParamSpec](https://typing.python.org/en/latest/spec/generics.html#paramspec)
890+
pub(crate) static INVALID_PARAMSPEC = {
891+
summary: "detects invalid ParamSpec usage",
892+
status: LintStatus::stable("0.0.1-alpha.1"),
893+
default_level: Level::Error,
894+
}
895+
}
896+
875897
declare_lint! {
876898
/// ## What it does
877899
/// Checks for the creation of invalid `TypeAliasType`s

0 commit comments

Comments
 (0)