Skip to content

Commit 35656cf

Browse files
[ty] Preserve deprecations on decorated callables (#28256)
## Summary We now report deprecations when `@deprecated` wraps a `Callable` returned by another decorator. Previously, we preserved the deprecation only while the decorated value remained a function literal: ```python from collections.abc import Callable from typing_extensions import deprecated def passthrough[**P, R](function: Callable[P, R]) -> Callable[P, R]: return function @deprecated("Use current instead") @PassThrough def old() -> None: pass old() # Now reports the deprecation. ``` We retain the deprecated declaration on `CallableType`, independently of its binding kind, and preserve it when binding methods or specializing signatures. This also covers implicit constructor calls and deprecated overload implementations, while explicit references and calls report only once. Addresses [the callable-decorator case raised in #28251](#28251 (comment)).
1 parent d2626c8 commit 35656cf

9 files changed

Lines changed: 239 additions & 59 deletions

File tree

‎crates/ty_python_semantic/resources/mdtest/deprecated.md‎

Lines changed: 139 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -91,10 +91,147 @@ class StaticMethodReplacement:
9191
StaticMethodReplacement.old() # error: [deprecated] "use replacement directly"
9292
```
9393

94+
## Callable replacements
95+
96+
```toml
97+
[environment]
98+
python-version = "3.12"
99+
```
100+
101+
An outer `@deprecated` decorator also applies when an inner decorator returns a `Callable` type.
102+
Both references and calls report the deprecation, and the decorated signature is preserved.
103+
104+
```py
105+
from collections.abc import Callable
106+
from typing_extensions import deprecated
107+
108+
def passthrough[**P, R](function: Callable[P, R]) -> Callable[P, R]:
109+
return function
110+
111+
@deprecated("use current instead")
112+
@passthrough
113+
def old(value: int) -> str:
114+
return str(value)
115+
116+
old # error: [deprecated] "use current instead"
117+
# error: [deprecated] "use current instead"
118+
reveal_type(old(1)) # revealed: str
119+
# error: [invalid-argument-type]
120+
old("wrong") # error: [deprecated] "use current instead"
121+
```
122+
123+
Replacing the callable with a different function discards the original deprecation. If an outer
124+
`@deprecated` wraps the replacement, its message is used instead.
125+
126+
```py
127+
def replace(function: Callable[[int], str]) -> Callable[[int], str]:
128+
return lambda value: str(value)
129+
130+
@replace
131+
@deprecated("discarded deprecation")
132+
@passthrough
133+
def replaced(value: int) -> str:
134+
return str(value)
135+
136+
replaced(1)
137+
138+
@deprecated("outer deprecation")
139+
@replace
140+
@deprecated("inner deprecation")
141+
@passthrough
142+
def replaced_again(value: int) -> str:
143+
return str(value)
144+
145+
replaced_again(1) # error: [deprecated] "outer deprecation"
146+
```
147+
148+
## Callable method replacements
149+
150+
```toml
151+
[environment]
152+
python-version = "3.12"
153+
```
154+
155+
Changing a method's descriptor kind or specializing its class preserves the deprecation.
156+
157+
```py
158+
from collections.abc import Callable
159+
from typing_extensions import deprecated
160+
161+
def passthrough[**P, R](function: Callable[P, R]) -> Callable[P, R]:
162+
return function
163+
164+
class C[T]:
165+
@deprecated("old initializer")
166+
@passthrough
167+
def __init__(self) -> None: ...
168+
@deprecated("old method")
169+
@passthrough
170+
def method(self, value: T) -> T:
171+
return value
172+
173+
@staticmethod
174+
@deprecated("old static method")
175+
@passthrough
176+
def static(value: int) -> int:
177+
return value
178+
179+
@classmethod
180+
@deprecated("old class method")
181+
@passthrough
182+
def class_method(cls, value: int) -> int:
183+
return value
184+
185+
c = C[int]() # error: [deprecated] "old initializer"
186+
c.__init__ # error: [deprecated] "old initializer"
187+
# error: [deprecated] "old method"
188+
reveal_type(c.method(1)) # revealed: int
189+
# error: [invalid-argument-type]
190+
c.method("wrong") # error: [deprecated] "old method"
191+
C.static(1) # error: [deprecated] "old static method"
192+
c.static(1) # error: [deprecated] "old static method"
193+
C.class_method(1) # error: [deprecated] "old class method"
194+
c.class_method(1) # error: [deprecated] "old class method"
195+
```
196+
197+
## Overloaded callable replacements
198+
199+
```toml
200+
[environment]
201+
python-version = "3.12"
202+
```
203+
204+
Deprecating a callable replacement for an overload implementation deprecates the whole function,
205+
while calls still use the declared overload signatures.
206+
207+
```py
208+
from collections.abc import Callable
209+
from typing import overload
210+
from typing_extensions import deprecated
211+
212+
def passthrough[**P, R](function: Callable[P, R]) -> Callable[P, R]:
213+
return function
214+
215+
@overload
216+
def overloaded(value: int) -> int: ...
217+
@overload
218+
def overloaded(value: str) -> str: ...
219+
@deprecated("old implementation")
220+
@passthrough
221+
def overloaded(value: int | str) -> int | str:
222+
return value
223+
224+
overloaded # error: [deprecated] "old implementation"
225+
# error: [deprecated] "old implementation"
226+
reveal_type(overloaded(1)) # revealed: int
227+
# error: [deprecated] "old implementation"
228+
reveal_type(overloaded("one")) # revealed: str
229+
```
230+
94231
## Callable-object replacements
95232

96-
`@deprecated` can also wrap other callable objects at runtime, but we currently only preserve the
97-
deprecation when an inner decorator returns a function literal.
233+
`@deprecated` can also wrap callable instances at runtime, but we do not yet preserve the
234+
deprecation when an inner decorator returns an instance type.
98235

99236
```py
100237
from collections.abc import Callable

‎crates/ty_python_semantic/src/types.rs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2398,6 +2398,7 @@ impl<'db> Type<'db> {
23982398
pub fn is_deprecated(&self, db: &'db dyn Db) -> bool {
23992399
match self {
24002400
Type::FunctionLiteral(f) => f.implementation_deprecated(db).is_some(),
2401+
Type::Callable(callable) => callable.deprecated(db).is_some(),
24012402
Type::ClassLiteral(c) => c.deprecated(db).is_some(),
24022403
_ => false,
24032404
}

‎crates/ty_python_semantic/src/types/call/bind.rs‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4538,6 +4538,9 @@ impl<'db> CallableBinding<'db> {
45384538
&self,
45394539
db: &'db dyn Db,
45404540
) -> impl Iterator<Item = OverloadLiteral<'db>> + Clone {
4541+
if let Type::Callable(callable) = self.signature_type {
4542+
return Either::Left(callable.deprecated(db).into_iter());
4543+
}
45414544
let function = match self.signature_type {
45424545
Type::FunctionLiteral(function) => Some(function),
45434546
Type::BoundMethod(bound) => Some(bound.function(db)),
@@ -4549,7 +4552,7 @@ impl<'db> CallableBinding<'db> {
45494552
if let Some(implementation) =
45504553
implementation.filter(|function| function.deprecated(db).is_some())
45514554
{
4552-
return Either::Left(std::iter::once(implementation));
4555+
return Either::Left(Some(implementation).into_iter());
45534556
}
45544557

45554558
Either::Right(self.selected_overloads().filter_map(move |(_, binding)| {

‎crates/ty_python_semantic/src/types/callable.rs‎

Lines changed: 47 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ use crate::{
1111
LiteralValueTypeKind, MemberLookupPolicy, Parameter, Parameters, Signature,
1212
SubclassOfInner, Type, TypeContext, TypeMapping, TypeVarBoundOrConstraints, UnionType,
1313
constraints::{ConstraintSet, IteratorConstraintsExtension},
14+
function::OverloadLiteral,
1415
known_instance::FunctoolsPartialInstance,
1516
relation::{TypeRelation, TypeRelationChecker},
1617
signatures::{CallableSignature, PartialSignatureApplication},
@@ -186,10 +187,9 @@ impl<'db> Type<'db> {
186187
.signatures(db)
187188
.into_iter()
188189
.map(|sig| sig.clone().with_return_type(Type::TypeVar(tvar)));
189-
CallableType::new(
190+
callable.with_signatures(
190191
db,
191192
CallableSignature::from_overloads(signatures),
192-
callable.kind(db),
193193
)
194194
}))
195195
}
@@ -206,10 +206,9 @@ impl<'db> Type<'db> {
206206
callable.signatures(db).into_iter().map(|sig| {
207207
sig.clone().with_return_type(Type::TypeVar(tvar))
208208
});
209-
callables.push(CallableType::new(
209+
callables.push(callable.with_signatures(
210210
db,
211211
CallableSignature::from_overloads(signatures),
212-
callable.kind(db),
213212
));
214213
}
215214
}
@@ -400,13 +399,18 @@ impl From<TypeRelation> for UpcastPolicy {
400399
/// It can be written in type expressions using `typing.Callable`. `lambda` expressions are
401400
/// inferred directly as `CallableType`s; all function-literal types are subtypes of a
402401
/// `CallableType`.
403-
#[salsa::interned(debug, heap_size=ruff_memory_usage::heap_size)]
402+
#[salsa::interned(debug, constructor=new_internal, heap_size=ruff_memory_usage::heap_size)]
404403
pub struct CallableType<'db> {
405404
#[returns(ref)]
406405
pub(crate) signatures: CallableSignature<'db>,
407406

408407
#[returns(copy)]
409408
pub(super) kind: CallableTypeKind,
409+
410+
/// The declaration on which `@deprecated` wrapped this callable. Retain the declaration
411+
/// for diagnostic names, source annotations, and deduplication, independently of binding kind.
412+
#[returns(copy)]
413+
pub(crate) deprecated: Option<OverloadLiteral<'db>>,
410414
}
411415

412416
pub(super) fn walk_callable_type<'db, V: visitor::TypeVisitor<'db> + ?Sized>(
@@ -423,6 +427,31 @@ pub(super) fn walk_callable_type<'db, V: visitor::TypeVisitor<'db> + ?Sized>(
423427
impl get_size2::GetSize for CallableType<'_> {}
424428

425429
impl<'db> CallableType<'db> {
430+
pub(crate) fn new<S>(db: &'db dyn Db, signatures: S, kind: CallableTypeKind) -> Self
431+
where
432+
S: salsa::Lookup<CallableSignature<'db>> + std::hash::Hash,
433+
CallableSignature<'db>: salsa::HashEqLike<S>,
434+
{
435+
Self::new_internal(db, signatures, kind, None)
436+
}
437+
438+
pub(crate) fn with_deprecated(self, db: &'db dyn Db, deprecated: OverloadLiteral<'db>) -> Self {
439+
Self::new_internal(db, self.signatures(db), self.kind(db), Some(deprecated))
440+
}
441+
442+
/// Replace the signatures without losing binding behavior or deprecation metadata.
443+
pub(crate) fn with_signatures<S>(self, db: &'db dyn Db, signatures: S) -> Self
444+
where
445+
S: salsa::Lookup<CallableSignature<'db>> + std::hash::Hash,
446+
CallableSignature<'db>: salsa::HashEqLike<S>,
447+
{
448+
Self::new_internal(db, signatures, self.kind(db), self.deprecated(db))
449+
}
450+
451+
pub(crate) fn with_kind(self, db: &'db dyn Db, kind: CallableTypeKind) -> Self {
452+
Self::new_internal(db, self.signatures(db), kind, self.deprecated(db))
453+
}
454+
426455
pub(crate) fn single(db: &'db dyn Db, signature: Signature<'db>) -> CallableType<'db> {
427456
CallableType::new(
428457
db,
@@ -483,7 +512,7 @@ impl<'db> CallableType<'db> {
483512
}
484513

485514
pub(crate) fn into_regular(self, db: &'db dyn Db) -> CallableType<'db> {
486-
CallableType::new(db, self.signatures(db), CallableTypeKind::Regular)
515+
self.with_kind(db, CallableTypeKind::Regular)
487516
}
488517

489518
/// Retain every parameter signature and its generic context, but erase return types
@@ -545,19 +574,15 @@ impl<'db> CallableType<'db> {
545574
return self.into_regular(db);
546575
}
547576

548-
CallableType::new(
549-
db,
550-
self.signatures(db).bind_self(db, env, self_type),
551-
self.kind(db),
552-
)
577+
self.with_signatures(db, self.signatures(db).bind_self(db, env, self_type))
553578
}
554579

555580
pub(crate) fn into_function_like(self, db: &'db dyn Db) -> CallableType<'db> {
556-
CallableType::new(db, self.signatures(db), CallableTypeKind::FunctionLike)
581+
self.with_kind(db, CallableTypeKind::FunctionLike)
557582
}
558583

559584
pub(crate) fn into_dunder_paramspec(self, db: &'db dyn Db) -> CallableType<'db> {
560-
CallableType::new(db, self.signatures(db), CallableTypeKind::DunderParamSpec)
585+
self.with_kind(db, CallableTypeKind::DunderParamSpec)
561586
}
562587

563588
pub(crate) fn apply_self(
@@ -576,11 +601,10 @@ impl<'db> CallableType<'db> {
576601
receiver_type: Type<'db>,
577602
self_type: Type<'db>,
578603
) -> CallableType<'db> {
579-
CallableType::new(
604+
self.with_signatures(
580605
db,
581606
self.signatures(db)
582607
.apply_self_with_receiver(db, env, receiver_type, self_type),
583-
self.kind(db),
584608
)
585609
}
586610

@@ -599,12 +623,13 @@ impl<'db> CallableType<'db> {
599623
div: Type<'db>,
600624
nested: bool,
601625
) -> Option<Self> {
602-
Some(CallableType::new(
603-
db,
604-
self.signatures(db)
605-
.recursive_type_normalized_impl(db, env, div, nested)?,
606-
self.kind(db),
607-
))
626+
Some(
627+
self.with_signatures(
628+
db,
629+
self.signatures(db)
630+
.recursive_type_normalized_impl(db, env, div, nested)?,
631+
),
632+
)
608633
}
609634

610635
pub(super) fn apply_type_mapping_impl<'a>(
@@ -618,11 +643,10 @@ impl<'db> CallableType<'db> {
618643
return replacements.get(&self).copied().unwrap_or(self);
619644
}
620645

621-
CallableType::new(
646+
self.with_signatures(
622647
db,
623648
self.signatures(db)
624649
.apply_type_mapping_impl(db, type_mapping, tcx, visitor),
625-
self.kind(db),
626650
)
627651
}
628652

‎crates/ty_python_semantic/src/types/function.rs‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -297,7 +297,11 @@ impl get_size2::GetSize for OverloadLiteral<'_> {}
297297

298298
#[salsa::tracked]
299299
impl<'db> OverloadLiteral<'db> {
300-
fn with_deprecated(self, db: &'db dyn Db, deprecated: DeprecatedInstance<'db>) -> Self {
300+
pub(super) fn with_deprecated(
301+
self,
302+
db: &'db dyn Db,
303+
deprecated: DeprecatedInstance<'db>,
304+
) -> Self {
301305
Self::new(
302306
db,
303307
self.name(db),
@@ -1228,12 +1232,11 @@ impl<'db> FunctionType<'db> {
12281232
self.implementation_callables(db)
12291233
.iter()
12301234
.map(|callable| {
1231-
CallableType::new(
1235+
callable.with_signatures(
12321236
db,
12331237
callable
12341238
.signatures(db)
12351239
.with_inherited_generic_context(db, inherited_generic_context),
1236-
callable.kind(db),
12371240
)
12381241
})
12391242
.collect()

‎crates/ty_python_semantic/src/types/generics.rs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -729,7 +729,7 @@ impl<'db> GenericContext<'db> {
729729
);
730730
let signatures =
731731
signatures.with_inherited_generic_context(db, generic_context);
732-
let replacement = CallableType::new(db, signatures, callable.kind(db));
732+
let replacement = callable.with_signatures(db, signatures);
733733

734734
Some((callable, replacement))
735735
})

0 commit comments

Comments
 (0)