[ty] Silence false positives for PEP-695 ParamSpec annotations (#18001)

## Summary

Suppress false positives for uses of PEP-695 `ParamSpec` in `Callable`
annotations:
```py
from typing_extensions import Callable

def f[**P](c: Callable[P, int]):
    pass
```

addresses a comment here:
https://github.com/astral-sh/ty/issues/157#issuecomment-2859284721

## Test Plan

Adapted Markdown tests
This commit is contained in:
David Peter 2025-05-10 11:59:25 +02:00 committed by GitHub
parent 235b74a310
commit cd1d906ffa
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 40 additions and 10 deletions

View file

@ -249,10 +249,12 @@ Using a `ParamSpec` in a `Callable` annotation:
```py
from typing_extensions import Callable
# TODO: Not an error; remove once `ParamSpec` is supported
# error: [invalid-type-form]
def _[**P1](c: Callable[P1, int]):
reveal_type(c) # revealed: (...) -> Unknown
reveal_type(P1.args) # revealed: @Todo(ParamSpec)
reveal_type(P1.kwargs) # revealed: @Todo(ParamSpec)
# TODO: Signature should be (**P1) -> int
reveal_type(c) # revealed: (...) -> int
```
And, using the legacy syntax:

View file

@ -662,7 +662,7 @@ impl<'db> Type<'db> {
pub fn contains_todo(&self, db: &'db dyn Db) -> bool {
match self {
Self::Dynamic(DynamicType::Todo(_)) => true,
Self::Dynamic(DynamicType::Todo(_) | DynamicType::TodoPEP695ParamSpec) => true,
Self::AlwaysFalsy
| Self::AlwaysTruthy
@ -703,7 +703,9 @@ impl<'db> Type<'db> {
}
Self::SubclassOf(subclass_of) => match subclass_of.subclass_of() {
SubclassOfInner::Dynamic(DynamicType::Todo(_)) => true,
SubclassOfInner::Dynamic(
DynamicType::Todo(_) | DynamicType::TodoPEP695ParamSpec,
) => true,
SubclassOfInner::Dynamic(DynamicType::Unknown | DynamicType::Any) => false,
SubclassOfInner::Class(_) => false,
},
@ -5502,6 +5504,9 @@ pub enum DynamicType {
///
/// 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,
}
impl std::fmt::Display for DynamicType {
@ -5512,6 +5517,13 @@ 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")
}
}
}
}
}

View file

@ -76,7 +76,7 @@ impl<'db> ClassBase<'db> {
ClassBase::Class(class) => class.name(db),
ClassBase::Dynamic(DynamicType::Any) => "Any",
ClassBase::Dynamic(DynamicType::Unknown) => "Unknown",
ClassBase::Dynamic(DynamicType::Todo(_)) => "@Todo",
ClassBase::Dynamic(DynamicType::Todo(_) | DynamicType::TodoPEP695ParamSpec) => "@Todo",
ClassBase::Protocol(_) => "Protocol",
ClassBase::Generic(_) => "Generic",
}

View file

@ -2612,7 +2612,7 @@ impl<'db> TypeInferenceBuilder<'db> {
default,
} = node;
self.infer_optional_expression(default.as_deref());
let pep_695_todo = todo_type!("PEP-695 ParamSpec definition types");
let pep_695_todo = Type::Dynamic(DynamicType::TodoPEP695ParamSpec);
self.add_declaration_with_binding(
node.into(),
definition,
@ -5797,8 +5797,16 @@ impl<'db> TypeInferenceBuilder<'db> {
| (_, any @ Type::Dynamic(DynamicType::Any), _) => Some(any),
(unknown @ Type::Dynamic(DynamicType::Unknown), _, _)
| (_, unknown @ Type::Dynamic(DynamicType::Unknown), _) => Some(unknown),
(todo @ Type::Dynamic(DynamicType::Todo(_)), _, _)
| (_, todo @ Type::Dynamic(DynamicType::Todo(_)), _) => Some(todo),
(
todo @ Type::Dynamic(DynamicType::Todo(_) | DynamicType::TodoPEP695ParamSpec),
_,
_,
)
| (
_,
todo @ Type::Dynamic(DynamicType::Todo(_) | DynamicType::TodoPEP695ParamSpec),
_,
) => Some(todo),
(Type::Never, _, _) | (_, Type::Never, _) => Some(Type::Never),
(Type::IntLiteral(n), Type::IntLiteral(m), ast::Operator::Add) => Some(
@ -8651,9 +8659,14 @@ impl<'db> TypeInferenceBuilder<'db> {
// `Callable[]`.
return None;
}
ast::Expr::Name(name)
if self.infer_name_load(name)
== Type::Dynamic(DynamicType::TodoPEP695ParamSpec) =>
{
return Some(Parameters::todo());
}
_ => {
if let Some(builder) = self.context.report_lint(&INVALID_TYPE_FORM, parameters) {
// TODO: Check whether `Expr::Name` is a ParamSpec
builder.into_diagnostic(format_args!(
"The first argument to `Callable` \
must be either a list of types, \

View file

@ -386,5 +386,8 @@ 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,
}
}