[ty] Fix method replacement overrides and error recovery Use structural instance replacement contracts for static and class methods, including override write checks. Preserve incompatible inferred instance values for error recovery after rejected assignments.
diff --git a/crates/ty_python_semantic/resources/mdtest/attributes.md b/crates/ty_python_semantic/resources/mdtest/attributes.md index 4d67127..016589b 100644 --- a/crates/ty_python_semantic/resources/mdtest/attributes.md +++ b/crates/ty_python_semantic/resources/mdtest/attributes.md
@@ -1612,6 +1612,40 @@ a.f1() # no diagnostic ``` +## Incompatible inferred method replacements + +After rejecting an incompatible assignment, subsequent reads retain the inferred value. In +particular, storing non-callable data does not make a truthiness test behave like a test of the +inherited method. + +```py +class Base: + def color(self) -> str: + return "blue" + +class Disabled(Base): + def __init__(self) -> None: + self.color = None # error: [invalid-assignment] + reveal_type(self.color) # revealed: None + if self.color: # error: [redundant-condition] "`None` is always falsy" + pass +``` + +The stored object's attributes also remain available for error recovery. + +```py +class Palette: + def get(self) -> str: + return "blue" + +class Custom(Base): + def __init__(self) -> None: + self.color = Palette() # error: [invalid-assignment] + reveal_type(self.color) # revealed: Palette + if self.color: # no diagnostic + reveal_type(self.color.get()) # revealed: str +``` + ## Gradual method replacements and recursive wrappers An unknown replacement must not introduce the unbound method into subsequent reads. Wrapping a @@ -1808,6 +1842,38 @@ Decoder.decode = staticmethod(Decoder.decode) ``` +### Structural replacements + +Static methods can be shadowed by other compatible callables, including callable objects. Their +parameters are preserved without binding an extra receiver. + +```py +from collections.abc import Callable + +class Decoder: + @staticmethod + def decode(data: bytes, /) -> str: + return data.decode() + +class Decode: + def __call__(self, data: bytes, /) -> str: + return data.decode() + +def replace(decoder: Decoder, callback: Callable[[bytes], str]) -> None: + decoder.decode = callback # no diagnostic + reveal_type(decoder.decode(b"hello")) # revealed: str + decoder.decode = Decode() # no diagnostic + reveal_type(decoder.decode(b"hello")) # revealed: str + decoder.decode = lambda data: data.decode() # no diagnostic + decoder.decode = lambda data: 0 # error: [invalid-assignment] + +def wrong_argument(data: int, /) -> str: + return str(data) + +def invalid_replacement(decoder: Decoder) -> None: + decoder.decode = wrong_argument # error: [invalid-assignment] +``` + ### Implicit `self` An unannotated `self` parameter can also shadow a static method with its underlying function. @@ -1905,6 +1971,53 @@ c.f = f # error: [invalid-assignment] ``` +## Shadowing class methods on instances + +A class method accessed on an instance binds the class before deriving the replacement signature. A +callable stored on the instance receives only the remaining arguments. + +```py +from collections.abc import Callable +from typing import Self + +class Factory: + @classmethod + def create(cls, value: int, /) -> Self: + return cls() + + def replace(self, unknown) -> None: + self.create = unknown # no diagnostic + +class Child(Factory): ... + +reveal_type(Child().create(1)) # revealed: Child + +class Create: + def __call__(self, value: int, /) -> Factory: + return Factory() + +def replace(factory: Factory, callback: Callable[[int], Factory]) -> None: + factory.create = callback # no diagnostic + reveal_type(factory.create(1)) # revealed: Factory + factory.create = Create() # no diagnostic + reveal_type(factory.create(1)) # revealed: Factory + factory.create = None # error: [invalid-assignment] + factory.create = lambda value: value # error: [invalid-assignment] + +def wrong_argument(value: str, /) -> Factory: + return Factory() + +def invalid_replacement(factory: Factory) -> None: + factory.create = wrong_argument # error: [invalid-assignment] +``` + +Assignments to the class itself still require a classmethod descriptor. + +```py +Factory.create = Factory.create # error: [invalid-assignment] +Factory.create = classmethod(Factory.create.__func__) # no diagnostic +``` + ## Accessing attributes on class objects When accessing attributes on class objects, they are always looked up on the type of the class
diff --git a/crates/ty_python_semantic/resources/mdtest/liskov.md b/crates/ty_python_semantic/resources/mdtest/liskov.md index a2592ed..b4b547f 100644 --- a/crates/ty_python_semantic/resources/mdtest/liskov.md +++ b/crates/ty_python_semantic/resources/mdtest/liskov.md
@@ -3122,6 +3122,57 @@ value: Callable[[], int] # error: [invalid-attribute-override] ``` +## Static and class methods overriding callable attributes + +Static and class methods preserve a callable attribute's instance-write contract: an instance can +shadow either method with a compatible callable. This also applies to a gradually typed `__call__` +attribute, where the concrete signature is left to the implementation. + +```py +from collections.abc import Callable + +class Method: + __call__: Callable[..., None] + +class StaticMethod(Method): + @staticmethod + def __call__() -> None: # no diagnostic + pass + +class ClassMethod(Method): + @classmethod + def __call__(cls) -> None: # no diagnostic + pass +``` + +A precise callable attribute also requires compatible reads and writes. An override cannot narrow +its return type if that would reject a replacement allowed by the base declaration. + +```py +class Callback: + callback: Callable[[], int] + +class StaticCallback(Callback): + @staticmethod + def callback() -> int: # no diagnostic + return 0 + +class ClassCallback(Callback): + @classmethod + def callback(cls) -> int: # no diagnostic + return 0 + +class NarrowStaticCallback(Callback): + @staticmethod + def callback() -> bool: # error: [invalid-mutable-override] + return True + +class NarrowClassCallback(Callback): + @classmethod + def callback(cls) -> bool: # error: [invalid-mutable-override] + return True +``` + ## Methods replacing inferred callable defaults A class assigned to an unannotated attribute is callable without binding a receiver. An overriding
diff --git a/crates/ty_python_semantic/src/types.rs b/crates/ty_python_semantic/src/types.rs index 1087e05..f043eb4 100644 --- a/crates/ty_python_semantic/src/types.rs +++ b/crates/ty_python_semantic/src/types.rs
@@ -5513,8 +5513,9 @@ let fallback_member = fallback_member.member(db); // Instance assignments shadow methods without invoking the replacement's descriptor. // Derive the storage contract from the class method after binding the actual receiver. - // Explicit instance annotations keep their own contract. - let fallback_member = if let Place::Defined(DefinedPlace { origin, .. }) = + // Explicit instance annotations keep their own contract. Preserve incompatible inferred + // values for error recovery instead of turning non-callable data into a callable. + let fallback_member = if let Place::Defined(DefinedPlace { origin, ty, .. }) = fallback_member.place && (!origin.is_declared() || fallback_member @@ -5522,6 +5523,7 @@ .contains(TypeQualifiers::METHOD_INSTANCE_ATTRIBUTE)) && let Some(contract) = meta_attr_ty.and_then(|method| method.instance_method_contract(db, env, receiver)) + && (origin.is_declared() || ty.is_assignable_to(db, env, contract)) { fallback_member.map_type(|_| contract) } else {
diff --git a/crates/ty_python_semantic/src/types/callable.rs b/crates/ty_python_semantic/src/types/callable.rs index 0bc47eb..9b7230f 100644 --- a/crates/ty_python_semantic/src/types/callable.rs +++ b/crates/ty_python_semantic/src/types/callable.rs
@@ -101,10 +101,11 @@ )) } - /// The callable contract for a value that shadows an ordinary method on an instance. + /// The callable contract for a value that shadows a method on an instance. /// /// The stored replacement is called directly, without descriptor binding. Preserve the /// method's bound signatures but not its function identity or nominal bound-method attributes. + /// Static methods keep all parameters; class methods bind the receiver's class. pub(super) fn instance_method_contract( self, db: &'db dyn Db, @@ -120,7 +121,7 @@ .instance_method_contract(db, env, receiver), // Class objects, including instances of custom metaclasses, retain the // class-attribute assignment rules. - _ if self.function_like_kind(db) == Some(CallableTypeKind::FunctionLike) + _ if self.function_like_kind(db).is_some() && !receiver.is_subtype_of(db, env, KnownClass::Type.to_instance(db, env)) => { self.function_like_dunder_get(db, env, Some(receiver), None)?
diff --git a/crates/ty_python_semantic/src/types/overrides/attributes.rs b/crates/ty_python_semantic/src/types/overrides/attributes.rs index a10c52f..3a15a96 100644 --- a/crates/ty_python_semantic/src/types/overrides/attributes.rs +++ b/crates/ty_python_semantic/src/types/overrides/attributes.rs
@@ -252,7 +252,18 @@ .unwrap_or(read), ) } else { - descriptor_write_domain(db, env, own_place.ty, receiver, read) + // Method replacements need only satisfy the bound signature, even when reads + // expose a function identity (as with static methods). Match assignment checking. + descriptor_write_domain( + db, + env, + own_place.ty, + receiver, + own_place + .ty + .instance_method_contract(db, env, receiver) + .unwrap_or(read), + ) }; (read, write) } else {