diff --git a/crates/ty/docs/rules.md b/crates/ty/docs/rules.md index 4218eee1af..951c364462 100644 --- a/crates/ty/docs/rules.md +++ b/crates/ty/docs/rules.md @@ -39,7 +39,7 @@ def test(): -> "int": Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -63,7 +63,7 @@ Calling a non-callable object will raise a `TypeError` at runtime. Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -95,7 +95,7 @@ f(int) # error Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -126,7 +126,7 @@ a = 1 Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -158,7 +158,7 @@ class C(A, B): ... Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -190,7 +190,7 @@ class B(A): ... Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -217,7 +217,7 @@ class B(A, A): ... Default level: error · Added in 0.0.1-alpha.12 · Related issues · -View source +View source @@ -329,7 +329,7 @@ def test(): -> "Literal[5]": Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -359,7 +359,7 @@ class C(A, B): ... Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -385,7 +385,7 @@ t[3] # IndexError: tuple index out of range Default level: error · Added in 0.0.1-alpha.12 · Related issues · -View source +View source @@ -474,7 +474,7 @@ an atypical memory layout. Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -501,7 +501,7 @@ func("foo") # error: [invalid-argument-type] Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -529,7 +529,7 @@ a: int = '' Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -563,7 +563,7 @@ C.instance_var = 3 # error: Cannot assign to instance variable Default level: error · Added in 0.0.1-alpha.19 · Related issues · -View source +View source @@ -599,7 +599,7 @@ asyncio.run(main()) Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -623,7 +623,7 @@ class A(42): ... # error: [invalid-base] Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -650,7 +650,7 @@ with 1: Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -679,7 +679,7 @@ a: str Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -723,7 +723,7 @@ except ZeroDivisionError: Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -756,7 +756,7 @@ class C[U](Generic[T]): ... Default level: error · Added in 0.0.1-alpha.17 · Related issues · -View source +View source @@ -795,7 +795,7 @@ carol = Person(name="Carol", age=25) # typo! Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -830,7 +830,7 @@ def f(t: TypeVar("U")): ... Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -864,7 +864,7 @@ class B(metaclass=f): ... Default level: error · Added in 0.0.1-alpha.19 · Related issues · -View source +View source @@ -896,7 +896,7 @@ TypeError: can only inherit from a NamedTuple type and Generic Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -946,7 +946,7 @@ def foo(x: int) -> int: ... Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -966,13 +966,44 @@ weakens a type checker's ability to accurately reason about your code. def f(a: int = ''): ... ``` +## `invalid-paramspec` + + +Default level: error · +Added in 0.0.1-alpha.1 · +Related issues · +View source + + + +**What it does** + +Checks for the creation of invalid `ParamSpec`s + +**Why is this bad?** + +There are several requirements that you must follow when creating a `ParamSpec`. + +**Examples** + +```python +from typing import ParamSpec + +P1 = ParamSpec("P1") # okay +P2 = ParamSpec("S2") # error: ParamSpec name must match the variable it's assigned to +``` + +**References** + +- [Typing spec: ParamSpec](https://typing.python.org/en/latest/spec/generics.html#paramspec) + ## `invalid-protocol` Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1006,7 +1037,7 @@ TypeError: Protocols can only inherit from other protocols, got Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1055,7 +1086,7 @@ def g(): Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1080,7 +1111,7 @@ def func() -> int: Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1138,7 +1169,7 @@ TODO #14889 Default level: error · Added in 0.0.1-alpha.6 · Related issues · -View source +View source @@ -1165,7 +1196,7 @@ NewAlias = TypeAliasType(get_name(), int) # error: TypeAliasType name mus Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1195,7 +1226,7 @@ TYPE_CHECKING = '' Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1225,7 +1256,7 @@ b: Annotated[int] # `Annotated` expects at least two arguments Default level: error · Added in 0.0.1-alpha.11 · Related issues · -View source +View source @@ -1259,7 +1290,7 @@ f(10) # Error Default level: error · Added in 0.0.1-alpha.11 · Related issues · -View source +View source @@ -1293,7 +1324,7 @@ class C: Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1328,7 +1359,7 @@ T = TypeVar('T', bound=str) # valid bound TypeVar Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1353,7 +1384,7 @@ func() # TypeError: func() missing 1 required positional argument: 'x' Default level: error · Added in 0.0.1-alpha.20 · Related issues · -View source +View source @@ -1386,7 +1417,7 @@ alice["age"] # KeyError Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1415,7 +1446,7 @@ func("string") # error: [no-matching-overload] Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1439,7 +1470,7 @@ Subscripting an object that does not support it will raise a `TypeError` at runt Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1465,7 +1496,7 @@ for i in 34: # TypeError: 'int' object is not iterable Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1492,7 +1523,7 @@ f(1, x=2) # Error raised here Default level: error · Added in 0.0.1-alpha.22 · Related issues · -View source +View source @@ -1550,7 +1581,7 @@ def test(): -> "int": Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1580,7 +1611,7 @@ static_assert(int(2.0 * 3.0) == 6) # error: does not have a statically known tr Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1609,7 +1640,7 @@ class B(A): ... # Error raised here Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1636,7 +1667,7 @@ f("foo") # Error raised here Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1664,7 +1695,7 @@ def _(x: int): Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1710,7 +1741,7 @@ class A: Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1737,7 +1768,7 @@ f(x=1, y=2) # Error raised here Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1765,7 +1796,7 @@ A().foo # AttributeError: 'A' object has no attribute 'foo' Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1790,7 +1821,7 @@ import foo # ModuleNotFoundError: No module named 'foo' Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1815,7 +1846,7 @@ print(x) # NameError: name 'x' is not defined Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1852,7 +1883,7 @@ b1 < b2 < b1 # exception raised here Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1880,7 +1911,7 @@ A() + A() # TypeError: unsupported operand type(s) for +: 'A' and 'A' Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1905,7 +1936,7 @@ l[1:10:0] # ValueError: slice step cannot be zero Default level: warn · Added in 0.0.1-alpha.20 · Related issues · -View source +View source @@ -1946,7 +1977,7 @@ class SubProto(BaseProto, Protocol): Default level: warn · Added in 0.0.1-alpha.16 · Related issues · -View source +View source @@ -2034,7 +2065,7 @@ a = 20 / 0 # type: ignore Default level: warn · Added in 0.0.1-alpha.22 · Related issues · -View source +View source @@ -2062,7 +2093,7 @@ A.c # AttributeError: type object 'A' has no attribute 'c' Default level: warn · Added in 0.0.1-alpha.22 · Related issues · -View source +View source @@ -2094,7 +2125,7 @@ A()[0] # TypeError: 'A' object is not subscriptable Default level: warn · Added in 0.0.1-alpha.22 · Related issues · -View source +View source @@ -2126,7 +2157,7 @@ from module import a # ImportError: cannot import name 'a' from 'module' Default level: warn · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -2153,7 +2184,7 @@ cast(int, f()) # Redundant Default level: warn · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -2177,7 +2208,7 @@ reveal_type(1) # NameError: name 'reveal_type' is not defined Default level: warn · Added in 0.0.1-alpha.15 · Related issues · -View source +View source @@ -2235,7 +2266,7 @@ def g(): Default level: warn · Added in 0.0.1-alpha.7 · Related issues · -View source +View source @@ -2274,7 +2305,7 @@ class D(C): ... # error: [unsupported-base] Default level: warn · Added in 0.0.1-alpha.22 · Related issues · -View source +View source @@ -2337,7 +2368,7 @@ def foo(x: int | str) -> int | str: Default level: ignore · Preview (since 0.0.1-alpha.1) · Related issues · -View source +View source @@ -2361,7 +2392,7 @@ Dividing by zero raises a `ZeroDivisionError` at runtime. Default level: ignore · Added in 0.0.1-alpha.1 · Related issues · -View source +View source diff --git a/crates/ty_ide/src/goto_type_definition.rs b/crates/ty_ide/src/goto_type_definition.rs index f39e5dc4b6..7d86743af4 100644 --- a/crates/ty_ide/src/goto_type_definition.rs +++ b/crates/ty_ide/src/goto_type_definition.rs @@ -276,10 +276,20 @@ mod tests { "#, ); - // TODO: Goto type definition currently doesn't work for type param specs - // because the inference doesn't support them yet. - // This snapshot should show a single target pointing to `T` - assert_snapshot!(test.goto_type_definition(), @"No type definitions found"); + assert_snapshot!(test.goto_type_definition(), @r" + info[goto-type-definition]: Type definition + --> main.py:2:14 + | + 2 | type Alias[**P = [int, str]] = Callable[P, int] + | ^ + | + info: Source + --> main.py:2:41 + | + 2 | type Alias[**P = [int, str]] = Callable[P, int] + | ^ + | + "); } #[test] diff --git a/crates/ty_ide/src/hover.rs b/crates/ty_ide/src/hover.rs index b555295678..1b348b82b9 100644 --- a/crates/ty_ide/src/hover.rs +++ b/crates/ty_ide/src/hover.rs @@ -1633,11 +1633,12 @@ def ab(a: int, *, c: int): "#, ); + // TODO: This should be `P@Alias ()` assert_snapshot!(test.hover(), @r" - @Todo + typing.ParamSpec --------------------------------------------- ```python - @Todo + typing.ParamSpec ``` --------------------------------------------- info[hover]: Hovered content is diff --git a/crates/ty_python_semantic/resources/mdtest/annotations/callable.md b/crates/ty_python_semantic/resources/mdtest/annotations/callable.md index 780b2a87db..e7e55f7a44 100644 --- a/crates/ty_python_semantic/resources/mdtest/annotations/callable.md +++ b/crates/ty_python_semantic/resources/mdtest/annotations/callable.md @@ -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 diff --git a/crates/ty_python_semantic/resources/mdtest/annotations/unsupported_special_forms.md b/crates/ty_python_semantic/resources/mdtest/annotations/unsupported_special_forms.md index c61a94a8d6..c5d737d9eb 100644 --- a/crates/ty_python_semantic/resources/mdtest/annotations/unsupported_special_forms.md +++ b/crates/ty_python_semantic/resources/mdtest/annotations/unsupported_special_forms.md @@ -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: diff --git a/crates/ty_python_semantic/resources/mdtest/generics/legacy/classes.md b/crates/ty_python_semantic/resources/mdtest/generics/legacy/classes.md index c520b7e883..a1f47c3b11 100644 --- a/crates/ty_python_semantic/resources/mdtest/generics/legacy/classes.md +++ b/crates/ty_python_semantic/resources/mdtest/generics/legacy/classes.md @@ -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 ``` diff --git a/crates/ty_python_semantic/resources/mdtest/paramspec.md b/crates/ty_python_semantic/resources/mdtest/paramspec.md new file mode 100644 index 0000000000..4ebc336d7f --- /dev/null +++ b/crates/ty_python_semantic/resources/mdtest/paramspec.md @@ -0,0 +1,159 @@ +# `ParamSpec` + +## Definition + +### Valid + +```py +from typing import ParamSpec + +P = ParamSpec("P") +reveal_type(type(P)) # revealed: +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. + + + +```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 +``` + + + +#### Invalid default + +```py +# error: [invalid-paramspec] +def foo[**P = int]() -> None: + pass +``` diff --git a/crates/ty_python_semantic/resources/mdtest/type_properties/is_assignable_to.md b/crates/ty_python_semantic/resources/mdtest/type_properties/is_assignable_to.md index a39b6a6f16..1386a9e158 100644 --- a/crates/ty_python_semantic/resources/mdtest/type_properties/is_assignable_to.md +++ b/crates/ty_python_semantic/resources/mdtest/type_properties/is_assignable_to.md @@ -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 diff --git a/crates/ty_python_semantic/src/types.rs b/crates/ty_python_semantic/src/types.rs index 59e1ef4030..bc75895833 100644 --- a/crates/ty_python_semantic/src/types.rs +++ b/crates/ty_python_semantic/src/types.rs @@ -4358,6 +4358,13 @@ impl<'db> Type<'db> { .into() } + Type::KnownInstance(KnownInstanceType::TypeVar(typevar)) + if typevar.kind(db).is_paramspec() + && matches!(name.as_str(), "args" | "kwargs") => + { + Place::bound(todo_type!("ParamSpecArgs / ParamSpecKwargs")).into() + } + Type::NominalInstance(..) | Type::ProtocolInstance(..) | Type::BooleanLiteral(..) @@ -7024,7 +7031,7 @@ impl<'db> Type<'db> { Type::TypeVar(bound_typevar) => { if matches!( bound_typevar.typevar(db).kind(db), - TypeVarKind::Legacy | TypeVarKind::TypingSelf + TypeVarKind::Legacy | TypeVarKind::TypingSelf | TypeVarKind::ParamSpec ) && binding_context.is_none_or(|binding_context| { bound_typevar.binding_context(db) == BindingContext::Definition(binding_context) }) { @@ -7743,6 +7750,9 @@ impl<'db> KnownInstanceType<'db> { fn class(self, db: &'db dyn Db) -> KnownClass { match self { Self::SubscriptedProtocol(_) | Self::SubscriptedGeneric(_) => KnownClass::SpecialForm, + Self::TypeVar(typevar_instance) if typevar_instance.kind(db).is_paramspec() => { + KnownClass::ParamSpec + } Self::TypeVar(_) => KnownClass::TypeVar, Self::TypeAliasType(TypeAliasType::PEP695(alias)) if alias.is_specialized(db) => { KnownClass::GenericAlias @@ -7808,7 +7818,13 @@ impl<'db> KnownInstanceType<'db> { // This is a legacy `TypeVar` _outside_ of any generic class or function, so we render // it as an instance of `typing.TypeVar`. Inside of a generic class or function, we'll // have a `Type::TypeVar(_)`, which is rendered as the typevar's name. - KnownInstanceType::TypeVar(_) => f.write_str("typing.TypeVar"), + KnownInstanceType::TypeVar(typevar_instance) => { + if typevar_instance.kind(self.db).is_paramspec() { + f.write_str("typing.ParamSpec") + } else { + f.write_str("typing.TypeVar") + } + } KnownInstanceType::Deprecated(_) => f.write_str("warnings.deprecated"), KnownInstanceType::Field(field) => { f.write_str("dataclasses.Field")?; @@ -7864,9 +7880,6 @@ pub enum DynamicType<'db> { /// /// This variant should be created with the `todo_type!` macro. Todo(TodoType), - /// A special Todo-variant for PEP-695 `ParamSpec` types. A temporary variant to detect and special- - /// case the handling of these types in `Callable` annotations. - TodoPEP695ParamSpec, /// A special Todo-variant for type aliases declared using `typing.TypeAlias`. /// A temporary variant to detect and special-case the handling of these aliases in autocomplete suggestions. TodoTypeAlias, @@ -7894,13 +7907,6 @@ impl std::fmt::Display for DynamicType<'_> { // `DynamicType::Todo`'s display should be explicit that is not a valid display of // any other type DynamicType::Todo(todo) => write!(f, "@Todo{todo}"), - DynamicType::TodoPEP695ParamSpec => { - if cfg!(debug_assertions) { - f.write_str("@Todo(ParamSpec)") - } else { - f.write_str("@Todo") - } - } DynamicType::TodoUnpack => { if cfg!(debug_assertions) { f.write_str("@Todo(typing.Unpack)") @@ -8239,12 +8245,20 @@ pub enum TypeVarKind { Pep695, /// `typing.Self` TypingSelf, + /// `P = ParamSpec("P")` + ParamSpec, + /// `def foo[**P]() -> None: ...` + Pep695ParamSpec, } impl TypeVarKind { const fn is_self(self) -> bool { matches!(self, Self::TypingSelf) } + + const fn is_paramspec(self) -> bool { + matches!(self, Self::ParamSpec | Self::Pep695ParamSpec) + } } /// The identity of a type variable. @@ -8597,6 +8611,15 @@ impl<'db> TypeVarInstance<'db> { let expr = &call_expr.arguments.find_keyword("default")?.value; Some(definition_expression_type(db, definition, expr)) } + // PEP 695 ParamSpec + DefinitionKind::ParamSpec(paramspec) => { + let paramspec_node = paramspec.node(&module); + Some(definition_expression_type( + db, + definition, + paramspec_node.default.as_ref()?, + )) + } _ => None, } } diff --git a/crates/ty_python_semantic/src/types/class_base.rs b/crates/ty_python_semantic/src/types/class_base.rs index bed18de8b9..071d4b92b7 100644 --- a/crates/ty_python_semantic/src/types/class_base.rs +++ b/crates/ty_python_semantic/src/types/class_base.rs @@ -49,10 +49,7 @@ impl<'db> ClassBase<'db> { ClassBase::Dynamic(DynamicType::Any) => "Any", ClassBase::Dynamic(DynamicType::Unknown) => "Unknown", ClassBase::Dynamic( - DynamicType::Todo(_) - | DynamicType::TodoPEP695ParamSpec - | DynamicType::TodoTypeAlias - | DynamicType::TodoUnpack, + DynamicType::Todo(_) | DynamicType::TodoTypeAlias | DynamicType::TodoUnpack, ) => "@Todo", ClassBase::Dynamic(DynamicType::Divergent(_)) => "Divergent", ClassBase::Protocol => "Protocol", diff --git a/crates/ty_python_semantic/src/types/diagnostic.rs b/crates/ty_python_semantic/src/types/diagnostic.rs index 5d647f108f..33efcc74fd 100644 --- a/crates/ty_python_semantic/src/types/diagnostic.rs +++ b/crates/ty_python_semantic/src/types/diagnostic.rs @@ -63,6 +63,7 @@ pub(crate) fn register_lints(registry: &mut LintRegistryBuilder) { registry.register_lint(&INVALID_EXCEPTION_CAUGHT); registry.register_lint(&INVALID_GENERIC_CLASS); registry.register_lint(&INVALID_LEGACY_TYPE_VARIABLE); + registry.register_lint(&INVALID_PARAMSPEC); registry.register_lint(&INVALID_TYPE_ALIAS_TYPE); registry.register_lint(&INVALID_METACLASS); registry.register_lint(&INVALID_OVERLOAD); @@ -880,6 +881,30 @@ declare_lint! { } } +declare_lint! { + /// ## What it does + /// Checks for the creation of invalid `ParamSpec`s + /// + /// ## Why is this bad? + /// There are several requirements that you must follow when creating a `ParamSpec`. + /// + /// ## Examples + /// ```python + /// from typing import ParamSpec + /// + /// P1 = ParamSpec("P1") # okay + /// P2 = ParamSpec("S2") # error: ParamSpec name must match the variable it's assigned to + /// ``` + /// + /// ## References + /// - [Typing spec: ParamSpec](https://typing.python.org/en/latest/spec/generics.html#paramspec) + pub(crate) static INVALID_PARAMSPEC = { + summary: "detects invalid ParamSpec usage", + status: LintStatus::stable("0.0.1-alpha.1"), + default_level: Level::Error, + } +} + declare_lint! { /// ## What it does /// Checks for the creation of invalid `TypeAliasType`s diff --git a/crates/ty_python_semantic/src/types/infer/builder.rs b/crates/ty_python_semantic/src/types/infer/builder.rs index e72b4af8db..8806dff536 100644 --- a/crates/ty_python_semantic/src/types/infer/builder.rs +++ b/crates/ty_python_semantic/src/types/infer/builder.rs @@ -59,11 +59,12 @@ use crate::types::diagnostic::{ DIVISION_BY_ZERO, DUPLICATE_KW_ONLY, INCONSISTENT_MRO, INVALID_ARGUMENT_TYPE, INVALID_ASSIGNMENT, INVALID_ATTRIBUTE_ACCESS, INVALID_BASE, INVALID_DECLARATION, INVALID_GENERIC_CLASS, INVALID_KEY, INVALID_LEGACY_TYPE_VARIABLE, INVALID_METACLASS, - INVALID_NAMED_TUPLE, INVALID_OVERLOAD, INVALID_PARAMETER_DEFAULT, INVALID_PROTOCOL, - INVALID_TYPE_FORM, INVALID_TYPE_GUARD_CALL, INVALID_TYPE_VARIABLE_CONSTRAINTS, - IncompatibleBases, NON_SUBSCRIPTABLE, POSSIBLY_MISSING_IMPLICIT_CALL, POSSIBLY_MISSING_IMPORT, - SUBCLASS_OF_FINAL_CLASS, UNDEFINED_REVEAL, UNRESOLVED_ATTRIBUTE, UNRESOLVED_GLOBAL, - UNRESOLVED_IMPORT, UNRESOLVED_REFERENCE, UNSUPPORTED_OPERATOR, USELESS_OVERLOAD_BODY, + INVALID_NAMED_TUPLE, INVALID_OVERLOAD, INVALID_PARAMETER_DEFAULT, INVALID_PARAMSPEC, + INVALID_PROTOCOL, INVALID_TYPE_FORM, INVALID_TYPE_GUARD_CALL, + INVALID_TYPE_VARIABLE_CONSTRAINTS, IncompatibleBases, NON_SUBSCRIPTABLE, + POSSIBLY_MISSING_IMPLICIT_CALL, POSSIBLY_MISSING_IMPORT, SUBCLASS_OF_FINAL_CLASS, + UNDEFINED_REVEAL, UNRESOLVED_ATTRIBUTE, UNRESOLVED_GLOBAL, UNRESOLVED_IMPORT, + UNRESOLVED_REFERENCE, UNSUPPORTED_OPERATOR, USELESS_OVERLOAD_BODY, hint_if_stdlib_attribute_exists_on_other_versions, hint_if_stdlib_submodule_exists_on_other_versions, report_attempted_protocol_instantiation, report_bad_dunder_set_call, report_cannot_pop_required_field_on_typed_dict, @@ -1296,6 +1297,9 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { DefinitionKind::TypeVar(typevar) => { self.infer_typevar_deferred(typevar.node(self.module())); } + DefinitionKind::ParamSpec(paramspec) => { + self.infer_paramspec_deferred(paramspec.node(self.module())); + } DefinitionKind::Assignment(assignment) => { self.infer_assignment_deferred(assignment.value(self.module())); } @@ -3182,18 +3186,120 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { let ast::TypeParamParamSpec { range: _, node_index: _, - name: _, + name, default, } = node; - self.infer_optional_expression(default.as_deref(), TypeContext::default()); - let pep_695_todo = Type::Dynamic(DynamicType::TodoPEP695ParamSpec); + if default.is_some() { + self.deferred.insert(definition); + } + let identity = TypeVarIdentity::new( + self.db(), + &name.id, + Some(definition), + TypeVarKind::Pep695ParamSpec, + ); + let ty = Type::KnownInstance(KnownInstanceType::TypeVar(TypeVarInstance::new( + self.db(), + identity, + None, // ParamSpec, when declared using PEP 695 syntax, has no bounds or constraints + None, // explicit_variance + default.as_deref().map(|_| TypeVarDefaultEvaluation::Lazy), + ))); self.add_declaration_with_binding( node.into(), definition, - &DeclaredAndInferredType::are_the_same_type(pep_695_todo), + &DeclaredAndInferredType::are_the_same_type(ty), ); } + fn infer_paramspec_deferred(&mut self, node: &ast::TypeParamParamSpec) { + let ast::TypeParamParamSpec { + range: _, + node_index: _, + name: _, + default: Some(default), + } = node + else { + return; + }; + let previous_deferred_state = + std::mem::replace(&mut self.deferred_state, DeferredExpressionState::Deferred); + let default_ty = self.infer_paramspec_default(default); + self.store_expression_type(default, default_ty); + self.deferred_state = previous_deferred_state; + } + + fn infer_paramspec_default(&mut self, default: &ast::Expr) -> Type<'db> { + // This is the same logic as `TypeInferenceBuilder::infer_callable_parameter_types` except + // for the subscript branch which is required for `Concatenate` but that cannot be + // specified in this context. + match default { + ast::Expr::EllipsisLiteral(_) => { + CallableType::single(self.db(), Signature::new(Parameters::gradual_form(), None)) + } + ast::Expr::List(ast::ExprList { elts, .. }) => { + let mut parameter_types = Vec::with_capacity(elts.len()); + + // Whether to infer `Todo` for the parameters + let mut return_todo = false; + + for param in elts { + let param_type = self.infer_type_expression(param); + // This is similar to what we currently do for inferring tuple type expression. + // We currently infer `Todo` for the parameters to avoid invalid diagnostics + // when trying to check for assignability or any other relation. For example, + // `*tuple[int, str]`, `Unpack[]`, etc. are not yet supported. + return_todo |= param_type.is_todo() + && matches!(param, ast::Expr::Starred(_) | ast::Expr::Subscript(_)); + parameter_types.push(param_type); + } + + let parameters = if return_todo { + // TODO: `Unpack` + Parameters::todo() + } else { + Parameters::new(parameter_types.iter().map(|param_type| { + Parameter::positional_only(None).with_annotated_type(*param_type) + })) + }; + + CallableType::single(self.db(), Signature::new(parameters, None)) + } + ast::Expr::Name(name) => { + let name_ty = self.infer_name_load(name); + let is_paramspec = match name_ty { + Type::KnownInstance(known_instance) => { + known_instance.class(self.db()) == KnownClass::ParamSpec + } + Type::NominalInstance(nominal) => { + nominal.has_known_class(self.db(), KnownClass::ParamSpec) + } + _ => false, + }; + if is_paramspec { + name_ty + } else { + if let Some(builder) = self.context.report_lint(&INVALID_PARAMSPEC, default) { + builder.into_diagnostic( + "The default value to `ParamSpec` must be either a list of types, \ + `ParamSpec`, or `...`", + ); + } + Type::unknown() + } + } + _ => { + if let Some(builder) = self.context.report_lint(&INVALID_PARAMSPEC, default) { + builder.into_diagnostic( + "The default value to `ParamSpec` must be either a list of types, \ + `ParamSpec`, or `...`", + ); + } + Type::unknown() + } + } + } + fn infer_typevartuple_definition( &mut self, node: &ast::TypeParamTypeVarTuple, @@ -4324,17 +4430,21 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { TypeContext::default(), ); - let typevar_class = callable_type + let ty = match callable_type .as_class_literal() .and_then(|cls| cls.known(self.db())) - .filter(|cls| { - matches!(cls, KnownClass::TypeVar | KnownClass::ExtensionsTypeVar) - }); - - let ty = if let Some(typevar_class) = typevar_class { - self.infer_legacy_typevar(target, call_expr, definition, typevar_class) - } else { - self.infer_call_expression_impl(call_expr, callable_type, tcx) + { + Some( + typevar_class @ (KnownClass::TypeVar | KnownClass::ExtensionsTypeVar), + ) => { + self.infer_legacy_typevar(target, call_expr, definition, typevar_class) + } + Some(KnownClass::ParamSpec) => { + self.infer_paramspec(target, call_expr, definition) + } + Some(_) | None => { + self.infer_call_expression_impl(call_expr, callable_type, tcx) + } }; self.store_expression_type(value, ty); @@ -4371,6 +4481,160 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { target_ty } + fn infer_paramspec( + &mut self, + target: &ast::Expr, + call_expr: &ast::ExprCall, + definition: Definition<'db>, + ) -> Type<'db> { + fn error<'db>( + context: &InferContext<'db, '_>, + message: impl std::fmt::Display, + node: impl Ranged, + ) -> Type<'db> { + if let Some(builder) = context.report_lint(&INVALID_PARAMSPEC, node) { + builder.into_diagnostic(message); + } + // If the call doesn't create a valid paramspec, we'll emit diagnostics and fall back to + // just creating a regular instance of `typing.ParamSpec`. + KnownClass::ParamSpec.to_instance(context.db()) + } + + let db = self.db(); + let arguments = &call_expr.arguments; + let assume_all_features = self.in_stub(); + let python_version = Program::get(db).python_version(db); + let have_features_from = + |version: PythonVersion| assume_all_features || python_version >= version; + + let mut default = None; + let mut name_param_ty = None; + + if arguments.args.len() > 1 { + return error( + &self.context, + "`ParamSpec` can only have one positional argument", + call_expr, + ); + } + + if let Some(starred) = arguments.args.iter().find(|arg| arg.is_starred_expr()) { + return error( + &self.context, + "Starred arguments are not supported in `ParamSpec` creation", + starred, + ); + } + + for kwarg in &arguments.keywords { + let Some(identifier) = kwarg.arg.as_ref() else { + return error( + &self.context, + "Starred arguments are not supported in `ParamSpec` creation", + kwarg, + ); + }; + match identifier.id().as_str() { + "name" => { + // Duplicate keyword argument is a syntax error, so we don't have to check if + // `name_param_ty.is_some()` here. + if !arguments.args.is_empty() { + return error( + &self.context, + "The `name` parameter of `ParamSpec` can only be provided once", + kwarg, + ); + } + name_param_ty = + Some(self.infer_expression(&kwarg.value, TypeContext::default())); + } + "bound" | "covariant" | "contravariant" | "infer_variance" => { + return error( + &self.context, + "The variance and bound arguments for `ParamSpec` do not have defined semantics yet", + call_expr, + ); + } + "default" => { + if !have_features_from(PythonVersion::PY313) { + // We don't return here; this error is informational since this will error + // at runtime, but the user's intent is plain, we may as well respect it. + error( + &self.context, + "The `default` parameter of `typing.ParamSpec` was added in Python 3.13", + kwarg, + ); + } + default = Some(TypeVarDefaultEvaluation::Lazy); + } + name => { + // We don't return here; this error is informational since this will error + // at runtime, but it will likely cause fewer cascading errors if we just + // ignore the unknown keyword and still understand as much of the typevar as we + // can. + error( + &self.context, + format_args!("Unknown keyword argument `{name}` in `ParamSpec` creation"), + kwarg, + ); + self.infer_expression(&kwarg.value, TypeContext::default()); + } + } + } + + let Some(name_param_ty) = name_param_ty.or_else(|| { + arguments + .find_positional(0) + .map(|arg| self.infer_expression(arg, TypeContext::default())) + }) else { + return error( + &self.context, + "The `name` parameter of `ParamSpec` is required.", + call_expr, + ); + }; + + let Some(name_param) = name_param_ty.as_string_literal().map(|name| name.value(db)) else { + return error( + &self.context, + "The first argument to `ParamSpec` must be a string literal", + call_expr, + ); + }; + + let ast::Expr::Name(ast::ExprName { + id: target_name, .. + }) = target + else { + return error( + &self.context, + "A `ParamSpec` definition must be a simple variable assignment", + target, + ); + }; + + if name_param != target_name { + return error( + &self.context, + format_args!( + "The name of a `ParamSpec` (`{name_param}`) must match \ + the name of the variable it is assigned to (`{target_name}`)" + ), + target, + ); + } + + if default.is_some() { + self.deferred.insert(definition); + } + + let identity = + TypeVarIdentity::new(db, target_name, Some(definition), TypeVarKind::ParamSpec); + Type::KnownInstance(KnownInstanceType::TypeVar(TypeVarInstance::new( + db, identity, None, None, default, + ))) + } + fn infer_legacy_typevar( &mut self, target: &ast::Expr, @@ -4617,8 +4881,11 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { } fn infer_assignment_deferred(&mut self, value: &ast::Expr) { - // Infer deferred bounds/constraints/defaults of a legacy TypeVar. - let ast::Expr::Call(ast::ExprCall { arguments, .. }) = value else { + // Infer deferred bounds/constraints/defaults of a legacy TypeVar / ParamSpec. + let ast::Expr::Call(ast::ExprCall { + func, arguments, .. + }) = value + else { return; }; for arg in arguments.args.iter().skip(1) { @@ -4628,7 +4895,14 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { self.infer_type_expression(&bound.value); } if let Some(default) = arguments.find_keyword("default") { - self.infer_type_expression(&default.value); + let func_ty = self.get_or_infer_expression(func, TypeContext::default()); + if func_ty.as_class_literal().is_some_and(|class_literal| { + class_literal.is_known(self.db(), KnownClass::ParamSpec) + }) { + self.infer_paramspec_default(&default.value); + } else { + self.infer_type_expression(&default.value); + } } } @@ -7047,22 +7321,33 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { .to_class_type(self.db()) .is_none_or(|enum_class| !class.is_subclass_of(self.db(), enum_class)) { - if matches!( - class.known(self.db()), - Some(KnownClass::TypeVar | KnownClass::ExtensionsTypeVar) - ) { - // Inference of correctly-placed `TypeVar` definitions is done in - // `TypeInferenceBuilder::infer_legacy_typevar`, and doesn't use the full - // call-binding machinery. If we reach here, it means that someone is trying to - // instantiate a `typing.TypeVar` in an invalid context. - if let Some(builder) = self - .context - .report_lint(&INVALID_LEGACY_TYPE_VARIABLE, call_expression) - { - builder.into_diagnostic( - "A `TypeVar` definition must be a simple variable assignment", - ); + // Inference of correctly-placed `TypeVar` and `ParamSpec` definitions is done in + // `TypeInferenceBuilder::infer_legacy_typevar` and + // `TypeInferenceBuilder::infer_paramspec`, and doesn't use the full + // call-binding machinery. If we reach here, it means that someone is trying to + // instantiate a `typing.TypeVar` and `typing.ParamSpec` in an invalid context. + match class.known(self.db()) { + Some(KnownClass::TypeVar | KnownClass::ExtensionsTypeVar) => { + if let Some(builder) = self + .context + .report_lint(&INVALID_LEGACY_TYPE_VARIABLE, call_expression) + { + builder.into_diagnostic( + "A `TypeVar` definition must be a simple variable assignment", + ); + } } + Some(KnownClass::ParamSpec) => { + if let Some(builder) = self + .context + .report_lint(&INVALID_PARAMSPEC, call_expression) + { + builder.into_diagnostic( + "A `ParamSpec` definition must be a simple variable assignment", + ); + } + } + _ => {} } let db = self.db(); @@ -8270,10 +8555,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { ( todo @ Type::Dynamic( - DynamicType::Todo(_) - | DynamicType::TodoPEP695ParamSpec - | DynamicType::TodoUnpack - | DynamicType::TodoTypeAlias, + DynamicType::Todo(_) | DynamicType::TodoUnpack | DynamicType::TodoTypeAlias, ), _, _, @@ -8281,10 +8563,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { | ( _, todo @ Type::Dynamic( - DynamicType::Todo(_) - | DynamicType::TodoPEP695ParamSpec - | DynamicType::TodoUnpack - | DynamicType::TodoTypeAlias, + DynamicType::Todo(_) | DynamicType::TodoUnpack | DynamicType::TodoTypeAlias, ), _, ) => Some(todo), diff --git a/crates/ty_python_semantic/src/types/infer/builder/type_expression.rs b/crates/ty_python_semantic/src/types/infer/builder/type_expression.rs index 1e1ff82c0b..c6b2bbbef0 100644 --- a/crates/ty_python_semantic/src/types/infer/builder/type_expression.rs +++ b/crates/ty_python_semantic/src/types/infer/builder/type_expression.rs @@ -1524,7 +1524,9 @@ impl<'db> TypeInferenceBuilder<'db, '_> { self.db(), self.infer_name_load(name), &|ty| match ty { - Type::Dynamic(DynamicType::TodoPEP695ParamSpec) => true, + Type::KnownInstance(known_instance) => { + known_instance.class(self.db()) == KnownClass::ParamSpec + } Type::NominalInstance(nominal) => { nominal.has_known_class(self.db(), KnownClass::ParamSpec) } diff --git a/crates/ty_python_semantic/src/types/type_ordering.rs b/crates/ty_python_semantic/src/types/type_ordering.rs index e45e0c9ba5..f6797f87d9 100644 --- a/crates/ty_python_semantic/src/types/type_ordering.rs +++ b/crates/ty_python_semantic/src/types/type_ordering.rs @@ -262,9 +262,6 @@ fn dynamic_elements_ordering(left: DynamicType, right: DynamicType) -> Ordering #[cfg(not(debug_assertions))] (DynamicType::Todo(TodoType), DynamicType::Todo(TodoType)) => Ordering::Equal, - (DynamicType::TodoPEP695ParamSpec, _) => Ordering::Less, - (_, DynamicType::TodoPEP695ParamSpec) => Ordering::Greater, - (DynamicType::TodoUnpack, _) => Ordering::Less, (_, DynamicType::TodoUnpack) => Ordering::Greater, diff --git a/crates/ty_server/tests/e2e/snapshots/e2e__commands__debug_command.snap b/crates/ty_server/tests/e2e/snapshots/e2e__commands__debug_command.snap index ba3b75028c..7373c4cf25 100644 --- a/crates/ty_server/tests/e2e/snapshots/e2e__commands__debug_command.snap +++ b/crates/ty_server/tests/e2e/snapshots/e2e__commands__debug_command.snap @@ -61,6 +61,7 @@ Settings: Settings { "invalid-named-tuple": Error (Default), "invalid-overload": Error (Default), "invalid-parameter-default": Error (Default), + "invalid-paramspec": Error (Default), "invalid-protocol": Error (Default), "invalid-raise": Error (Default), "invalid-return-type": Error (Default), diff --git a/ty.schema.json b/ty.schema.json index 55d5bdf996..cae55e4a1b 100644 --- a/ty.schema.json +++ b/ty.schema.json @@ -643,6 +643,16 @@ } ] }, + "invalid-paramspec": { + "title": "detects invalid ParamSpec usage", + "description": "## What it does\nChecks for the creation of invalid `ParamSpec`s\n\n## Why is this bad?\nThere are several requirements that you must follow when creating a `ParamSpec`.\n\n## Examples\n```python\nfrom typing import ParamSpec\n\nP1 = ParamSpec(\"P1\") # okay\nP2 = ParamSpec(\"S2\") # error: ParamSpec name must match the variable it's assigned to\n```\n\n## References\n- [Typing spec: ParamSpec](https://typing.python.org/en/latest/spec/generics.html#paramspec)", + "default": "error", + "oneOf": [ + { + "$ref": "#/definitions/Level" + } + ] + }, "invalid-protocol": { "title": "detects invalid protocol class definitions", "description": "## What it does\nChecks for protocol classes that will raise `TypeError` at runtime.\n\n## Why is this bad?\nAn invalidly defined protocol class may lead to the type checker inferring\nunexpected things. It may also lead to `TypeError`s at runtime.\n\n## Examples\nA `Protocol` class cannot inherit from a non-`Protocol` class;\nthis raises a `TypeError` at runtime:\n\n```pycon\n>>> from typing import Protocol\n>>> class Foo(int, Protocol): ...\n...\nTraceback (most recent call last):\n File \"\", line 1, in \n class Foo(int, Protocol): ...\nTypeError: Protocols can only inherit from other protocols, got \n```",