[ty] Check method definitions on subclasses for Liskov violations (#21436)
This commit is contained in:
@@ -1904,6 +1904,7 @@ we only consider the attribute assignment to be valid if the assigned attribute
|
||||
from typing import Literal
|
||||
|
||||
class Date:
|
||||
# error: [invalid-method-override]
|
||||
def __setattr__(self, name: Literal["day", "month", "year"], value: int) -> None:
|
||||
pass
|
||||
|
||||
|
||||
@@ -501,8 +501,8 @@ class A[T]:
|
||||
return a
|
||||
|
||||
class B[T](A[T]):
|
||||
def f(self, b: T) -> T:
|
||||
return super().f(b)
|
||||
def f(self, a: T) -> T:
|
||||
return super().f(a)
|
||||
```
|
||||
|
||||
## Invalid Usages
|
||||
|
||||
@@ -24,10 +24,10 @@ class GtReturnType: ...
|
||||
class GeReturnType: ...
|
||||
|
||||
class A:
|
||||
def __eq__(self, other: A) -> EqReturnType:
|
||||
def __eq__(self, other: A) -> EqReturnType: # error: [invalid-method-override]
|
||||
return EqReturnType()
|
||||
|
||||
def __ne__(self, other: A) -> NeReturnType:
|
||||
def __ne__(self, other: A) -> NeReturnType: # error: [invalid-method-override]
|
||||
return NeReturnType()
|
||||
|
||||
def __lt__(self, other: A) -> LtReturnType:
|
||||
@@ -66,10 +66,10 @@ class GtReturnType: ...
|
||||
class GeReturnType: ...
|
||||
|
||||
class A:
|
||||
def __eq__(self, other: B) -> EqReturnType:
|
||||
def __eq__(self, other: B) -> EqReturnType: # error: [invalid-method-override]
|
||||
return EqReturnType()
|
||||
|
||||
def __ne__(self, other: B) -> NeReturnType:
|
||||
def __ne__(self, other: B) -> NeReturnType: # error: [invalid-method-override]
|
||||
return NeReturnType()
|
||||
|
||||
def __lt__(self, other: B) -> LtReturnType:
|
||||
@@ -111,10 +111,10 @@ class GtReturnType: ...
|
||||
class GeReturnType: ...
|
||||
|
||||
class A:
|
||||
def __eq__(self, other: B) -> EqReturnType:
|
||||
def __eq__(self, other: B) -> EqReturnType: # error: [invalid-method-override]
|
||||
return EqReturnType()
|
||||
|
||||
def __ne__(self, other: B) -> NeReturnType:
|
||||
def __ne__(self, other: B) -> NeReturnType: # error: [invalid-method-override]
|
||||
return NeReturnType()
|
||||
|
||||
def __lt__(self, other: B) -> LtReturnType:
|
||||
@@ -132,12 +132,10 @@ class A:
|
||||
class Unrelated: ...
|
||||
|
||||
class B:
|
||||
# To override builtins.object.__eq__ and builtins.object.__ne__
|
||||
# TODO these should emit an invalid override diagnostic
|
||||
def __eq__(self, other: Unrelated) -> B:
|
||||
def __eq__(self, other: Unrelated) -> B: # error: [invalid-method-override]
|
||||
return B()
|
||||
|
||||
def __ne__(self, other: Unrelated) -> B:
|
||||
def __ne__(self, other: Unrelated) -> B: # error: [invalid-method-override]
|
||||
return B()
|
||||
|
||||
# Because `object.__eq__` and `object.__ne__` accept `object` in typeshed,
|
||||
@@ -180,10 +178,10 @@ class GtReturnType: ...
|
||||
class GeReturnType: ...
|
||||
|
||||
class A:
|
||||
def __eq__(self, other: A) -> A:
|
||||
def __eq__(self, other: A) -> A: # error: [invalid-method-override]
|
||||
return A()
|
||||
|
||||
def __ne__(self, other: A) -> A:
|
||||
def __ne__(self, other: A) -> A: # error: [invalid-method-override]
|
||||
return A()
|
||||
|
||||
def __lt__(self, other: A) -> A:
|
||||
@@ -199,22 +197,22 @@ class A:
|
||||
return A()
|
||||
|
||||
class B(A):
|
||||
def __eq__(self, other: A) -> EqReturnType:
|
||||
def __eq__(self, other: A) -> EqReturnType: # error: [invalid-method-override]
|
||||
return EqReturnType()
|
||||
|
||||
def __ne__(self, other: A) -> NeReturnType:
|
||||
def __ne__(self, other: A) -> NeReturnType: # error: [invalid-method-override]
|
||||
return NeReturnType()
|
||||
|
||||
def __lt__(self, other: A) -> LtReturnType:
|
||||
def __lt__(self, other: A) -> LtReturnType: # error: [invalid-method-override]
|
||||
return LtReturnType()
|
||||
|
||||
def __le__(self, other: A) -> LeReturnType:
|
||||
def __le__(self, other: A) -> LeReturnType: # error: [invalid-method-override]
|
||||
return LeReturnType()
|
||||
|
||||
def __gt__(self, other: A) -> GtReturnType:
|
||||
def __gt__(self, other: A) -> GtReturnType: # error: [invalid-method-override]
|
||||
return GtReturnType()
|
||||
|
||||
def __ge__(self, other: A) -> GeReturnType:
|
||||
def __ge__(self, other: A) -> GeReturnType: # error: [invalid-method-override]
|
||||
return GeReturnType()
|
||||
|
||||
reveal_type(A() == B()) # revealed: EqReturnType
|
||||
@@ -243,10 +241,10 @@ class A:
|
||||
return A()
|
||||
|
||||
class B(A):
|
||||
def __lt__(self, other: int) -> B:
|
||||
def __lt__(self, other: int) -> B: # error: [invalid-method-override]
|
||||
return B()
|
||||
|
||||
def __gt__(self, other: int) -> B:
|
||||
def __gt__(self, other: int) -> B: # error: [invalid-method-override]
|
||||
return B()
|
||||
|
||||
reveal_type(A() < B()) # revealed: A
|
||||
@@ -291,11 +289,10 @@ Please refer to the [docs](https://docs.python.org/3/reference/datamodel.html#ob
|
||||
from __future__ import annotations
|
||||
|
||||
class A:
|
||||
# TODO both these overrides should emit invalid-override diagnostic
|
||||
def __eq__(self, other: int) -> A:
|
||||
def __eq__(self, other: int) -> A: # error: [invalid-method-override]
|
||||
return A()
|
||||
|
||||
def __ne__(self, other: int) -> A:
|
||||
def __ne__(self, other: int) -> A: # error: [invalid-method-override]
|
||||
return A()
|
||||
|
||||
reveal_type(A() == A()) # revealed: bool
|
||||
|
||||
@@ -155,10 +155,10 @@ class GtReturnType: ...
|
||||
class GeReturnType: ...
|
||||
|
||||
class A:
|
||||
def __eq__(self, o: object) -> EqReturnType:
|
||||
def __eq__(self, o: object) -> EqReturnType: # error: [invalid-method-override]
|
||||
return EqReturnType()
|
||||
|
||||
def __ne__(self, o: object) -> NeReturnType:
|
||||
def __ne__(self, o: object) -> NeReturnType: # error: [invalid-method-override]
|
||||
return NeReturnType()
|
||||
|
||||
def __lt__(self, o: A) -> LtReturnType:
|
||||
@@ -386,6 +386,7 @@ class NotBoolable:
|
||||
__bool__: None = None
|
||||
|
||||
class A:
|
||||
# error: [invalid-method-override]
|
||||
def __eq__(self, other) -> NotBoolable:
|
||||
return NotBoolable()
|
||||
|
||||
|
||||
@@ -103,8 +103,7 @@ class UnknownLengthSubclassWithDunderLenOverridden(tuple[int, ...]):
|
||||
reveal_type(len(UnknownLengthSubclassWithDunderLenOverridden())) # revealed: Literal[42]
|
||||
|
||||
class FixedLengthSubclassWithDunderLenOverridden(tuple[int]):
|
||||
# TODO: we should complain about this as a Liskov violation (incompatible override)
|
||||
def __len__(self) -> Literal[42]:
|
||||
def __len__(self) -> Literal[42]: # error: [invalid-method-override]
|
||||
return 42
|
||||
|
||||
reveal_type(len(FixedLengthSubclassWithDunderLenOverridden((1,)))) # revealed: Literal[42]
|
||||
|
||||
525
crates/ty_python_semantic/resources/mdtest/liskov.md
Normal file
525
crates/ty_python_semantic/resources/mdtest/liskov.md
Normal file
@@ -0,0 +1,525 @@
|
||||
# The Liskov Substitution Principle
|
||||
|
||||
The Liskov Substitution Principle provides the basis for many of the assumptions a type checker
|
||||
generally makes about types in Python:
|
||||
|
||||
> Subtype Requirement: Let `ϕ(x)` be a property provable about objects `x` of type `T`. Then
|
||||
> `ϕ(y)` should be true for objects `y` of type `S` where `S` is a subtype of `T`.
|
||||
|
||||
In order for a type checker's assumptions to be sound, it is crucial for the type checker to enforce
|
||||
the Liskov Substitution Principle on code that it checks. In practice, this usually manifests as
|
||||
several checks for a type checker to perform when it checks a subclass `B` of a class `A`:
|
||||
|
||||
1. Read-only attributes should only ever be overridden covariantly: if a property `A.p` resolves to
|
||||
`int` when accessed, accessing `B.p` should either resolve to `int` or a subtype of `int`.
|
||||
1. Method return types should only ever be overridden covariantly: if a method `A.f` returns `int`
|
||||
when called, calling `B.f` should also resolve to `int or a subtype of`int\`.
|
||||
1. Method parameters should only ever be overridden contravariantly: if a method `A.f` can be called
|
||||
with an argument of type `bool`, then the method `B.f` must also be callable with type `bool`
|
||||
(though it is permitted for the override to also accept other types)
|
||||
1. Mutable attributes should only ever be overridden invariantly: if a mutable attribute `A.attr`
|
||||
resolves to type `str`, it can only be overridden on a subclass with exactly the same type.
|
||||
|
||||
## Method return types
|
||||
|
||||
<!-- snapshot-diagnostics -->
|
||||
|
||||
```pyi
|
||||
class Super:
|
||||
def method(self) -> int: ...
|
||||
|
||||
class Sub1(Super):
|
||||
def method(self) -> int: ... # fine
|
||||
|
||||
class Sub2(Super):
|
||||
def method(self) -> bool: ... # fine: `bool` is a subtype of `int`
|
||||
|
||||
class Sub3(Super):
|
||||
def method(self) -> object: ... # error: [invalid-method-override]
|
||||
|
||||
class Sub4(Super):
|
||||
def method(self) -> str: ... # error: [invalid-method-override]
|
||||
```
|
||||
|
||||
## Method parameters
|
||||
|
||||
<!-- snapshot-diagnostics -->
|
||||
|
||||
```pyi
|
||||
class Super:
|
||||
def method(self, x: int, /): ...
|
||||
|
||||
class Sub1(Super):
|
||||
def method(self, x: int, /): ... # fine
|
||||
|
||||
class Sub2(Super):
|
||||
def method(self, x: object, /): ... # fine: `method` still accepts any argument of type `int`
|
||||
|
||||
class Sub4(Super):
|
||||
def method(self, x: int | str, /): ... # fine
|
||||
|
||||
class Sub5(Super):
|
||||
def method(self, x: int): ... # fine: `x` can still be passed positionally
|
||||
|
||||
class Sub6(Super):
|
||||
# fine: `method()` can still be called with just a single argument
|
||||
def method(self, x: int, *args): ...
|
||||
|
||||
class Sub7(Super):
|
||||
def method(self, x: int, **kwargs): ... # fine
|
||||
|
||||
class Sub8(Super):
|
||||
def method(self, x: int, *args, **kwargs): ... # fine
|
||||
|
||||
class Sub9(Super):
|
||||
def method(self, x: int, extra_positional_arg=42, /): ... # fine
|
||||
|
||||
class Sub10(Super):
|
||||
def method(self, x: int, extra_pos_or_kw_arg=42): ... # fine
|
||||
|
||||
class Sub11(Super):
|
||||
def method(self, x: int, *, extra_kw_only_arg=42): ... # fine
|
||||
|
||||
class Sub12(Super):
|
||||
# Some calls permitted by the superclass are now no longer allowed
|
||||
# (the method can no longer be passed any arguments!)
|
||||
def method(self, /): ... # error: [invalid-method-override]
|
||||
|
||||
class Sub13(Super):
|
||||
# Some calls permitted by the superclass are now no longer allowed
|
||||
# (the method can no longer be passed exactly one argument!)
|
||||
def method(self, x, y, /): ... # error: [invalid-method-override]
|
||||
|
||||
class Sub14(Super):
|
||||
# Some calls permitted by the superclass are now no longer allowed
|
||||
# (x can no longer be passed positionally!)
|
||||
def method(self, /, *, x): ... # error: [invalid-method-override]
|
||||
|
||||
class Sub15(Super):
|
||||
# Some calls permitted by the superclass are now no longer allowed
|
||||
# (x can no longer be passed any integer -- it now requires a bool!)
|
||||
def method(self, x: bool, /): ... # error: [invalid-method-override]
|
||||
|
||||
class Super2:
|
||||
def method2(self, x): ...
|
||||
|
||||
class Sub16(Super2):
|
||||
def method2(self, x, /): ... # error: [invalid-method-override]
|
||||
|
||||
class Sub17(Super2):
|
||||
def method2(self, *, x): ... # error: [invalid-method-override]
|
||||
|
||||
class Super3:
|
||||
def method3(self, *, x): ...
|
||||
|
||||
class Sub18(Super3):
|
||||
def method3(self, x): ... # fine: `x` can still be used as a keyword argument
|
||||
|
||||
class Sub19(Super3):
|
||||
def method3(self, x, /): ... # error: [invalid-method-override]
|
||||
|
||||
class Super4:
|
||||
def method(self, *args: int, **kwargs: str): ...
|
||||
|
||||
class Sub20(Super4):
|
||||
def method(self, *args: object, **kwargs: object): ... # fine
|
||||
|
||||
class Sub21(Super4):
|
||||
def method(self, *args): ... # error: [invalid-method-override]
|
||||
|
||||
class Sub22(Super4):
|
||||
def method(self, **kwargs): ... # error: [invalid-method-override]
|
||||
|
||||
class Sub23(Super4):
|
||||
def method(self, x, *args, y, **kwargs): ... # error: [invalid-method-override]
|
||||
```
|
||||
|
||||
## The entire class hierarchy is checked
|
||||
|
||||
If a child class's method definition is Liskov-compatible with the method definition on its parent
|
||||
class, Liskov compatibility must also nonetheless be checked with respect to the method definition
|
||||
on its grandparent class. This is because type checkers will treat the child class as a subtype of
|
||||
the grandparent class just as much as they treat it as a subtype of the parent class, so
|
||||
substitutability with respect to the grandparent class is just as important:
|
||||
|
||||
<!-- snapshot-diagnostics -->
|
||||
|
||||
`stub.pyi`:
|
||||
|
||||
```pyi
|
||||
from typing import Any
|
||||
|
||||
class Grandparent:
|
||||
def method(self, x: int) -> None: ...
|
||||
|
||||
class Parent(Grandparent):
|
||||
def method(self, x: str) -> None: ... # error: [invalid-method-override]
|
||||
|
||||
class Child(Parent):
|
||||
# compatible with the signature of `Parent.method`, but not with `Grandparent.method`:
|
||||
def method(self, x: str) -> None: ... # error: [invalid-method-override]
|
||||
|
||||
class OtherChild(Parent):
|
||||
# compatible with the signature of `Grandparent.method`, but not with `Parent.method`:
|
||||
def method(self, x: int) -> None: ... # error: [invalid-method-override]
|
||||
|
||||
class GradualParent(Grandparent):
|
||||
def method(self, x: Any) -> None: ...
|
||||
|
||||
class ThirdChild(GradualParent):
|
||||
# `GradualParent.method` is compatible with the signature of `Grandparent.method`,
|
||||
# and `ThirdChild.method` is compatible with the signature of `GradualParent.method`,
|
||||
# but `ThirdChild.method` is not compatible with the signature of `Grandparent.method`
|
||||
def method(self, x: str) -> None: ... # error: [invalid-method-override]
|
||||
```
|
||||
|
||||
`other_stub.pyi`:
|
||||
|
||||
```pyi
|
||||
class A:
|
||||
def get(self, default): ...
|
||||
|
||||
class B(A):
|
||||
def get(self, default, /): ... # error: [invalid-method-override]
|
||||
|
||||
get = 56
|
||||
|
||||
class C(B):
|
||||
# `get` appears in the symbol table of `C`,
|
||||
# but that doesn't confuse our diagnostic...
|
||||
foo = get
|
||||
|
||||
class D(C):
|
||||
# compatible with `C.get` and `B.get`, but not with `A.get`
|
||||
def get(self, my_default): ... # error: [invalid-method-override]
|
||||
```
|
||||
|
||||
## Non-generic methods on generic classes work as expected
|
||||
|
||||
```toml
|
||||
[environment]
|
||||
python-version = "3.12"
|
||||
```
|
||||
|
||||
```pyi
|
||||
class A[T]:
|
||||
def method(self, x: T) -> None: ...
|
||||
|
||||
class B[T](A[T]):
|
||||
def method(self, x: T) -> None: ... # fine
|
||||
|
||||
class C(A[int]):
|
||||
def method(self, x: int) -> None: ... # fine
|
||||
|
||||
class D[T](A[T]):
|
||||
def method(self, x: object) -> None: ... # fine
|
||||
|
||||
class E(A[int]):
|
||||
def method(self, x: object) -> None: ... # fine
|
||||
|
||||
class F[T](A[T]):
|
||||
# TODO: we should emit `invalid-method-override` on this:
|
||||
# `str` is not necessarily a supertype of `T`!
|
||||
def method(self, x: str) -> None: ...
|
||||
|
||||
class G(A[int]):
|
||||
def method(self, x: bool) -> None: ... # error: [invalid-method-override]
|
||||
```
|
||||
|
||||
## Generic methods on non-generic classes work as expected
|
||||
|
||||
```toml
|
||||
[environment]
|
||||
python-version = "3.12"
|
||||
```
|
||||
|
||||
```pyi
|
||||
from typing import Never, Self
|
||||
|
||||
class A:
|
||||
def method[T](self, x: T) -> T: ...
|
||||
|
||||
class B(A):
|
||||
def method[T](self, x: T) -> T: ... # fine
|
||||
|
||||
class C(A):
|
||||
def method(self, x: object) -> Never: ... # fine
|
||||
|
||||
class D(A):
|
||||
# TODO: we should emit [invalid-method-override] here:
|
||||
# `A.method` accepts an argument of any type,
|
||||
# but `D.method` only accepts `int`s
|
||||
def method(self, x: int) -> int: ...
|
||||
|
||||
class A2:
|
||||
def method(self, x: int) -> int: ...
|
||||
|
||||
class B2(A2):
|
||||
# fine: although `B2.method()` will not always return an `int`,
|
||||
# an instance of `B2` can be substituted wherever an instance of `A2` is expected,
|
||||
# and it *will* always return an `int` if it is passed an `int`
|
||||
# (which is all that will be allowed if an instance of `A2` is expected)
|
||||
def method[T](self, x: T) -> T: ...
|
||||
|
||||
class C2(A2):
|
||||
def method[T: int](self, x: T) -> T: ...
|
||||
|
||||
class D2(A2):
|
||||
# The type variable is bound to a type disjoint from `int`,
|
||||
# so the method will not accept integers, and therefore this is an invalid override
|
||||
def method[T: str](self, x: T) -> T: ... # error: [invalid-method-override]
|
||||
|
||||
class A3:
|
||||
def method(self) -> Self: ...
|
||||
|
||||
class B3(A3):
|
||||
def method(self) -> Self: ... # fine
|
||||
|
||||
class C3(A3):
|
||||
# TODO: should this be allowed?
|
||||
# Mypy/pyright/pyrefly all allow it,
|
||||
# but conceptually it seems similar to `B4.method` below,
|
||||
# which mypy/pyrefly agree is a Liskov violation
|
||||
# (pyright disagrees as of 20/11/2025: https://github.com/microsoft/pyright/issues/11128)
|
||||
# when called on a subclass, `C3.method()` will not return an
|
||||
# instance of that subclass
|
||||
def method(self) -> C3: ...
|
||||
|
||||
class D3(A3):
|
||||
def method(self: Self) -> Self: ... # fine
|
||||
|
||||
class E3(A3):
|
||||
def method(self: E3) -> Self: ... # fine
|
||||
|
||||
class F3(A3):
|
||||
def method(self: A3) -> Self: ... # fine
|
||||
|
||||
class G3(A3):
|
||||
def method(self: object) -> Self: ... # fine
|
||||
|
||||
class H3(A3):
|
||||
# TODO: we should emit `invalid-method-override` here
|
||||
# (`A3.method()` can be called on any instance of `A3`,
|
||||
# but `H3.method()` can only be called on objects that are
|
||||
# instances of `str`)
|
||||
def method(self: str) -> Self: ...
|
||||
|
||||
class I3(A3):
|
||||
# TODO: we should emit `invalid-method-override` here
|
||||
# (`I3.method()` cannot be called with any inhabited type!)
|
||||
def method(self: Never) -> Self: ...
|
||||
|
||||
class A4:
|
||||
def method[T: int](self, x: T) -> T: ...
|
||||
|
||||
class B4(A4):
|
||||
# TODO: we should emit `invalid-method-override` here.
|
||||
# `A4.method` promises that if it is passed a `bool`, it will return a `bool`,
|
||||
# but this is not necessarily true for `B4.method`: if passed a `bool`,
|
||||
# it could return a non-`bool` `int`!
|
||||
def method(self, x: int) -> int: ...
|
||||
```
|
||||
|
||||
## Generic methods on generic classes work as expected
|
||||
|
||||
```toml
|
||||
[environment]
|
||||
python-version = "3.12"
|
||||
```
|
||||
|
||||
```pyi
|
||||
from typing import Never
|
||||
|
||||
class A[T]:
|
||||
def method[S](self, x: T, y: S) -> S: ...
|
||||
|
||||
class B[T](A[T]):
|
||||
def method[S](self, x: T, y: S) -> S: ... # fine
|
||||
|
||||
class C(A[int]):
|
||||
def method[S](self, x: int, y: S) -> S: ... # fine
|
||||
|
||||
class D[T](A[T]):
|
||||
def method[S](self, x: object, y: S) -> S: ... # fine
|
||||
|
||||
class E(A[int]):
|
||||
def method[S](self, x: object, y: S) -> S: ... # fine
|
||||
|
||||
class F(A[int]):
|
||||
def method(self, x: object, y: object) -> Never: ... # fine
|
||||
|
||||
class A2[T]:
|
||||
def method(self, x: T, y: int) -> int: ...
|
||||
|
||||
class B2[T](A2[T]):
|
||||
def method[S](self, x: T, y: S) -> S: ... # fine
|
||||
```
|
||||
|
||||
## Fully qualified names are used in diagnostics where appropriate
|
||||
|
||||
<!-- snapshot-diagnostics -->
|
||||
|
||||
`a.pyi`:
|
||||
|
||||
```pyi
|
||||
class A:
|
||||
def foo(self, x): ...
|
||||
```
|
||||
|
||||
`b.pyi`:
|
||||
|
||||
```pyi
|
||||
import a
|
||||
|
||||
class A(a.A):
|
||||
def foo(self, y): ... # error: [invalid-method-override]
|
||||
```
|
||||
|
||||
## Excluded methods
|
||||
|
||||
Certain special constructor methods are excluded from Liskov checks. None of the following classes
|
||||
cause us to emit any errors, therefore:
|
||||
|
||||
```toml
|
||||
# This is so that the dataclasses machinery will generate `__replace__` methods for us
|
||||
# (the synthesized `__replace__` methods should not be reported as invalid overrides!)
|
||||
[environment]
|
||||
python-version = "3.13"
|
||||
```
|
||||
|
||||
```pyi
|
||||
from dataclasses import dataclass
|
||||
from typing_extensions import Self
|
||||
|
||||
class Grandparent: ...
|
||||
class Parent(Grandparent):
|
||||
def __new__(cls, x: int) -> Self: ...
|
||||
def __init__(self, x: int) -> None: ...
|
||||
|
||||
class Child(Parent):
|
||||
def __new__(cls, x: str, y: str) -> Self: ...
|
||||
def __init__(self, x: str, y: str) -> Self: ...
|
||||
|
||||
@dataclass(init=False)
|
||||
class DataSuper:
|
||||
x: int
|
||||
|
||||
def __post_init__(self, x: int) -> None:
|
||||
self.x = x
|
||||
|
||||
@dataclass(init=False)
|
||||
class DataSub(DataSuper):
|
||||
y: str
|
||||
|
||||
def __post_init__(self, x: int, y: str) -> None:
|
||||
self.y = y
|
||||
super().__post_init__(x)
|
||||
```
|
||||
|
||||
## Edge case: function defined in another module and then assigned in a class body
|
||||
|
||||
<!-- snapshot-diagnostics -->
|
||||
|
||||
`foo.pyi`:
|
||||
|
||||
```pyi
|
||||
def x(self, y: str): ...
|
||||
```
|
||||
|
||||
`bar.pyi`:
|
||||
|
||||
```pyi
|
||||
import foo
|
||||
|
||||
class A:
|
||||
def x(self, y: int): ...
|
||||
|
||||
class B(A):
|
||||
x = foo.x # error: [invalid-method-override]
|
||||
|
||||
class C:
|
||||
x = foo.x
|
||||
|
||||
class D(C):
|
||||
def x(self, y: int): ... # error: [invalid-method-override]
|
||||
```
|
||||
|
||||
## Bad override of `__eq__`
|
||||
|
||||
<!-- snapshot-diagnostics -->
|
||||
|
||||
```py
|
||||
class Bad:
|
||||
x: int
|
||||
def __eq__(self, other: "Bad") -> bool: # error: [invalid-method-override]
|
||||
return self.x == other.x
|
||||
```
|
||||
|
||||
## Synthesized methods
|
||||
|
||||
`NamedTuple` classes and dataclasses both have methods generated at runtime that do not have
|
||||
source-code definitions. There are several scenarios to consider here:
|
||||
|
||||
1. A synthesized method on a superclass is overridden by a "normal" (not synthesized) method on a
|
||||
subclass
|
||||
1. A "normal" method on a superclass is overridden by a synthesized method on a subclass
|
||||
1. A synthesized method on a superclass is overridden by a synthesized method on a subclass
|
||||
|
||||
<!-- snapshot-diagnostics -->
|
||||
|
||||
```pyi
|
||||
from dataclasses import dataclass
|
||||
from typing import NamedTuple
|
||||
|
||||
@dataclass(order=True)
|
||||
class Foo:
|
||||
x: int
|
||||
|
||||
class Bar(Foo):
|
||||
def __lt__(self, other: Bar) -> bool: ... # error: [invalid-method-override]
|
||||
|
||||
# TODO: specifying `order=True` on the subclass means that a `__lt__` method is
|
||||
# generated that is incompatible with the generated `__lt__` method on the superclass.
|
||||
# We could consider detecting this and emitting a diagnostic, though maybe it shouldn't
|
||||
# be `invalid-method-override` since we'd emit it on the class definition rather than
|
||||
# on any method definition. Note also that no other type checker complains about this
|
||||
# as of 2025-11-21.
|
||||
@dataclass(order=True)
|
||||
class Bar2(Foo):
|
||||
y: str
|
||||
|
||||
# TODO: Although this class does not override any methods of `Foo`, the design of the
|
||||
# `order=True` stdlib dataclasses feature itself arguably violates the Liskov Substitution
|
||||
# Principle! Instances of `Bar3` cannot be substituted wherever an instance of `Foo` is
|
||||
# expected, because the generated `__lt__` method on `Foo` raises an error unless the r.h.s.
|
||||
# and `l.h.s.` have exactly the same `__class__` (it does not permit instances of `Foo` to
|
||||
# be compared with instances of subclasses of `Foo`).
|
||||
#
|
||||
# Many users would probably like their type checkers to alert them to cases where instances
|
||||
# of subclasses cannot be substituted for instances of superclasses, as this violates many
|
||||
# assumptions a type checker will make and makes it likely that a type checker will fail to
|
||||
# catch type errors elsewhere in the user's code. We could therefore consider treating all
|
||||
# `order=True` dataclasses as implicitly `@final` in order to enforce soundness. However,
|
||||
# this probably shouldn't be reported with the same error code as Liskov violations, since
|
||||
# the error does not stem from any method signatures written by the user. The example is
|
||||
# only included here for completeness.
|
||||
#
|
||||
# Note that no other type checker catches this error as of 2025-11-21.
|
||||
class Bar3(Foo): ...
|
||||
|
||||
class Eggs:
|
||||
def __lt__(self, other: Eggs) -> bool: ...
|
||||
|
||||
# TODO: the generated `Ham.__lt__` method here incompatibly overrides `Eggs.__lt__`.
|
||||
# We could consider emitting a diagnostic here. As of 2025-11-21, mypy reports a
|
||||
# diagnostic here but pyright and pyrefly do not.
|
||||
@dataclass(order=True)
|
||||
class Ham(Eggs):
|
||||
x: int
|
||||
|
||||
class Baz(NamedTuple):
|
||||
x: int
|
||||
|
||||
class Spam(Baz):
|
||||
def _asdict(self) -> tuple[int, ...]: ... # error: [invalid-method-override]
|
||||
```
|
||||
@@ -3069,18 +3069,15 @@ from typing import Protocol
|
||||
from ty_extensions import static_assert, is_subtype_of, is_equivalent_to, is_disjoint_from
|
||||
|
||||
class HasRepr(Protocol):
|
||||
# TODO: we should emit a diagnostic here complaining about a Liskov violation
|
||||
# (it incompatibly overrides `__repr__` from `object`, a supertype of `HasRepr`)
|
||||
# error: [invalid-method-override]
|
||||
def __repr__(self) -> object: ...
|
||||
|
||||
class HasReprRecursive(Protocol):
|
||||
# TODO: we should emit a diagnostic here complaining about a Liskov violation
|
||||
# (it incompatibly overrides `__repr__` from `object`, a supertype of `HasReprRecursive`)
|
||||
# error: [invalid-method-override]
|
||||
def __repr__(self) -> "HasReprRecursive": ...
|
||||
|
||||
class HasReprRecursiveAndFoo(Protocol):
|
||||
# TODO: we should emit a diagnostic here complaining about a Liskov violation
|
||||
# (it incompatibly overrides `__repr__` from `object`, a supertype of `HasReprRecursiveAndFoo`)
|
||||
# error: [invalid-method-override]
|
||||
def __repr__(self) -> "HasReprRecursiveAndFoo": ...
|
||||
foo: int
|
||||
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
---
|
||||
source: crates/ty_test/src/lib.rs
|
||||
expression: snapshot
|
||||
---
|
||||
---
|
||||
mdtest name: liskov.md - The Liskov Substitution Principle - Bad override of `__eq__`
|
||||
mdtest path: crates/ty_python_semantic/resources/mdtest/liskov.md
|
||||
---
|
||||
|
||||
# Python source files
|
||||
|
||||
## mdtest_snippet.py
|
||||
|
||||
```
|
||||
1 | class Bad:
|
||||
2 | x: int
|
||||
3 | def __eq__(self, other: "Bad") -> bool: # error: [invalid-method-override]
|
||||
4 | return self.x == other.x
|
||||
```
|
||||
|
||||
# Diagnostics
|
||||
|
||||
```
|
||||
error[invalid-method-override]: Invalid override of method `__eq__`
|
||||
--> src/mdtest_snippet.py:3:9
|
||||
|
|
||||
1 | class Bad:
|
||||
2 | x: int
|
||||
3 | def __eq__(self, other: "Bad") -> bool: # error: [invalid-method-override]
|
||||
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Definition is incompatible with `object.__eq__`
|
||||
4 | return self.x == other.x
|
||||
|
|
||||
::: stdlib/builtins.pyi:142:9
|
||||
|
|
||||
140 | def __setattr__(self, name: str, value: Any, /) -> None: ...
|
||||
141 | def __delattr__(self, name: str, /) -> None: ...
|
||||
142 | def __eq__(self, value: object, /) -> bool: ...
|
||||
| -------------------------------------- `object.__eq__` defined here
|
||||
143 | def __ne__(self, value: object, /) -> bool: ...
|
||||
144 | def __str__(self) -> str: ... # noqa: Y029
|
||||
|
|
||||
info: This violates the Liskov Substitution Principle
|
||||
help: It is recommended for `__eq__` to work with arbitrary objects, for example:
|
||||
help
|
||||
help: def __eq__(self, other: object) -> bool:
|
||||
help: if not isinstance(other, Bad):
|
||||
help: return False
|
||||
help: return <logic to compare two `Bad` instances>
|
||||
help
|
||||
info: rule `invalid-method-override` is enabled by default
|
||||
|
||||
```
|
||||
@@ -0,0 +1,82 @@
|
||||
---
|
||||
source: crates/ty_test/src/lib.rs
|
||||
expression: snapshot
|
||||
---
|
||||
---
|
||||
mdtest name: liskov.md - The Liskov Substitution Principle - Edge case: function defined in another module and then assigned in a class body
|
||||
mdtest path: crates/ty_python_semantic/resources/mdtest/liskov.md
|
||||
---
|
||||
|
||||
# Python source files
|
||||
|
||||
## foo.pyi
|
||||
|
||||
```
|
||||
1 | def x(self, y: str): ...
|
||||
```
|
||||
|
||||
## bar.pyi
|
||||
|
||||
```
|
||||
1 | import foo
|
||||
2 |
|
||||
3 | class A:
|
||||
4 | def x(self, y: int): ...
|
||||
5 |
|
||||
6 | class B(A):
|
||||
7 | x = foo.x # error: [invalid-method-override]
|
||||
8 |
|
||||
9 | class C:
|
||||
10 | x = foo.x
|
||||
11 |
|
||||
12 | class D(C):
|
||||
13 | def x(self, y: int): ... # error: [invalid-method-override]
|
||||
```
|
||||
|
||||
# Diagnostics
|
||||
|
||||
```
|
||||
error[invalid-method-override]: Invalid override of method `x`
|
||||
--> src/bar.pyi:4:9
|
||||
|
|
||||
3 | class A:
|
||||
4 | def x(self, y: int): ...
|
||||
| --------------- `A.x` defined here
|
||||
5 |
|
||||
6 | class B(A):
|
||||
7 | x = foo.x # error: [invalid-method-override]
|
||||
| ^^^^^^^^^ Definition is incompatible with `A.x`
|
||||
8 |
|
||||
9 | class C:
|
||||
|
|
||||
::: src/foo.pyi:1:5
|
||||
|
|
||||
1 | def x(self, y: str): ...
|
||||
| --------------- Signature of `B.x`
|
||||
|
|
||||
info: This violates the Liskov Substitution Principle
|
||||
info: rule `invalid-method-override` is enabled by default
|
||||
|
||||
```
|
||||
|
||||
```
|
||||
error[invalid-method-override]: Invalid override of method `x`
|
||||
--> src/bar.pyi:10:5
|
||||
|
|
||||
9 | class C:
|
||||
10 | x = foo.x
|
||||
| --------- `C.x` defined here
|
||||
11 |
|
||||
12 | class D(C):
|
||||
13 | def x(self, y: int): ... # error: [invalid-method-override]
|
||||
| ^^^^^^^^^^^^^^^ Definition is incompatible with `C.x`
|
||||
|
|
||||
::: src/foo.pyi:1:5
|
||||
|
|
||||
1 | def x(self, y: str): ...
|
||||
| --------------- Signature of `C.x`
|
||||
|
|
||||
info: This violates the Liskov Substitution Principle
|
||||
info: rule `invalid-method-override` is enabled by default
|
||||
|
||||
```
|
||||
@@ -0,0 +1,47 @@
|
||||
---
|
||||
source: crates/ty_test/src/lib.rs
|
||||
expression: snapshot
|
||||
---
|
||||
---
|
||||
mdtest name: liskov.md - The Liskov Substitution Principle - Fully qualified names are used in diagnostics where appropriate
|
||||
mdtest path: crates/ty_python_semantic/resources/mdtest/liskov.md
|
||||
---
|
||||
|
||||
# Python source files
|
||||
|
||||
## a.pyi
|
||||
|
||||
```
|
||||
1 | class A:
|
||||
2 | def foo(self, x): ...
|
||||
```
|
||||
|
||||
## b.pyi
|
||||
|
||||
```
|
||||
1 | import a
|
||||
2 |
|
||||
3 | class A(a.A):
|
||||
4 | def foo(self, y): ... # error: [invalid-method-override]
|
||||
```
|
||||
|
||||
# Diagnostics
|
||||
|
||||
```
|
||||
error[invalid-method-override]: Invalid override of method `foo`
|
||||
--> src/b.pyi:4:9
|
||||
|
|
||||
3 | class A(a.A):
|
||||
4 | def foo(self, y): ... # error: [invalid-method-override]
|
||||
| ^^^^^^^^^^^^ Definition is incompatible with `a.A.foo`
|
||||
|
|
||||
::: src/a.pyi:2:9
|
||||
|
|
||||
1 | class A:
|
||||
2 | def foo(self, x): ...
|
||||
| ------------ `a.A.foo` defined here
|
||||
|
|
||||
info: This violates the Liskov Substitution Principle
|
||||
info: rule `invalid-method-override` is enabled by default
|
||||
|
||||
```
|
||||
@@ -0,0 +1,331 @@
|
||||
---
|
||||
source: crates/ty_test/src/lib.rs
|
||||
expression: snapshot
|
||||
---
|
||||
---
|
||||
mdtest name: liskov.md - The Liskov Substitution Principle - Method parameters
|
||||
mdtest path: crates/ty_python_semantic/resources/mdtest/liskov.md
|
||||
---
|
||||
|
||||
# Python source files
|
||||
|
||||
## mdtest_snippet.pyi
|
||||
|
||||
```
|
||||
1 | class Super:
|
||||
2 | def method(self, x: int, /): ...
|
||||
3 |
|
||||
4 | class Sub1(Super):
|
||||
5 | def method(self, x: int, /): ... # fine
|
||||
6 |
|
||||
7 | class Sub2(Super):
|
||||
8 | def method(self, x: object, /): ... # fine: `method` still accepts any argument of type `int`
|
||||
9 |
|
||||
10 | class Sub4(Super):
|
||||
11 | def method(self, x: int | str, /): ... # fine
|
||||
12 |
|
||||
13 | class Sub5(Super):
|
||||
14 | def method(self, x: int): ... # fine: `x` can still be passed positionally
|
||||
15 |
|
||||
16 | class Sub6(Super):
|
||||
17 | # fine: `method()` can still be called with just a single argument
|
||||
18 | def method(self, x: int, *args): ...
|
||||
19 |
|
||||
20 | class Sub7(Super):
|
||||
21 | def method(self, x: int, **kwargs): ... # fine
|
||||
22 |
|
||||
23 | class Sub8(Super):
|
||||
24 | def method(self, x: int, *args, **kwargs): ... # fine
|
||||
25 |
|
||||
26 | class Sub9(Super):
|
||||
27 | def method(self, x: int, extra_positional_arg=42, /): ... # fine
|
||||
28 |
|
||||
29 | class Sub10(Super):
|
||||
30 | def method(self, x: int, extra_pos_or_kw_arg=42): ... # fine
|
||||
31 |
|
||||
32 | class Sub11(Super):
|
||||
33 | def method(self, x: int, *, extra_kw_only_arg=42): ... # fine
|
||||
34 |
|
||||
35 | class Sub12(Super):
|
||||
36 | # Some calls permitted by the superclass are now no longer allowed
|
||||
37 | # (the method can no longer be passed any arguments!)
|
||||
38 | def method(self, /): ... # error: [invalid-method-override]
|
||||
39 |
|
||||
40 | class Sub13(Super):
|
||||
41 | # Some calls permitted by the superclass are now no longer allowed
|
||||
42 | # (the method can no longer be passed exactly one argument!)
|
||||
43 | def method(self, x, y, /): ... # error: [invalid-method-override]
|
||||
44 |
|
||||
45 | class Sub14(Super):
|
||||
46 | # Some calls permitted by the superclass are now no longer allowed
|
||||
47 | # (x can no longer be passed positionally!)
|
||||
48 | def method(self, /, *, x): ... # error: [invalid-method-override]
|
||||
49 |
|
||||
50 | class Sub15(Super):
|
||||
51 | # Some calls permitted by the superclass are now no longer allowed
|
||||
52 | # (x can no longer be passed any integer -- it now requires a bool!)
|
||||
53 | def method(self, x: bool, /): ... # error: [invalid-method-override]
|
||||
54 |
|
||||
55 | class Super2:
|
||||
56 | def method2(self, x): ...
|
||||
57 |
|
||||
58 | class Sub16(Super2):
|
||||
59 | def method2(self, x, /): ... # error: [invalid-method-override]
|
||||
60 |
|
||||
61 | class Sub17(Super2):
|
||||
62 | def method2(self, *, x): ... # error: [invalid-method-override]
|
||||
63 |
|
||||
64 | class Super3:
|
||||
65 | def method3(self, *, x): ...
|
||||
66 |
|
||||
67 | class Sub18(Super3):
|
||||
68 | def method3(self, x): ... # fine: `x` can still be used as a keyword argument
|
||||
69 |
|
||||
70 | class Sub19(Super3):
|
||||
71 | def method3(self, x, /): ... # error: [invalid-method-override]
|
||||
72 |
|
||||
73 | class Super4:
|
||||
74 | def method(self, *args: int, **kwargs: str): ...
|
||||
75 |
|
||||
76 | class Sub20(Super4):
|
||||
77 | def method(self, *args: object, **kwargs: object): ... # fine
|
||||
78 |
|
||||
79 | class Sub21(Super4):
|
||||
80 | def method(self, *args): ... # error: [invalid-method-override]
|
||||
81 |
|
||||
82 | class Sub22(Super4):
|
||||
83 | def method(self, **kwargs): ... # error: [invalid-method-override]
|
||||
84 |
|
||||
85 | class Sub23(Super4):
|
||||
86 | def method(self, x, *args, y, **kwargs): ... # error: [invalid-method-override]
|
||||
```
|
||||
|
||||
# Diagnostics
|
||||
|
||||
```
|
||||
error[invalid-method-override]: Invalid override of method `method`
|
||||
--> src/mdtest_snippet.pyi:38:9
|
||||
|
|
||||
36 | # Some calls permitted by the superclass are now no longer allowed
|
||||
37 | # (the method can no longer be passed any arguments!)
|
||||
38 | def method(self, /): ... # error: [invalid-method-override]
|
||||
| ^^^^^^^^^^^^^^^ Definition is incompatible with `Super.method`
|
||||
39 |
|
||||
40 | class Sub13(Super):
|
||||
|
|
||||
::: src/mdtest_snippet.pyi:2:9
|
||||
|
|
||||
1 | class Super:
|
||||
2 | def method(self, x: int, /): ...
|
||||
| ----------------------- `Super.method` defined here
|
||||
3 |
|
||||
4 | class Sub1(Super):
|
||||
|
|
||||
info: This violates the Liskov Substitution Principle
|
||||
info: rule `invalid-method-override` is enabled by default
|
||||
|
||||
```
|
||||
|
||||
```
|
||||
error[invalid-method-override]: Invalid override of method `method`
|
||||
--> src/mdtest_snippet.pyi:43:9
|
||||
|
|
||||
41 | # Some calls permitted by the superclass are now no longer allowed
|
||||
42 | # (the method can no longer be passed exactly one argument!)
|
||||
43 | def method(self, x, y, /): ... # error: [invalid-method-override]
|
||||
| ^^^^^^^^^^^^^^^^^^^^^ Definition is incompatible with `Super.method`
|
||||
44 |
|
||||
45 | class Sub14(Super):
|
||||
|
|
||||
::: src/mdtest_snippet.pyi:2:9
|
||||
|
|
||||
1 | class Super:
|
||||
2 | def method(self, x: int, /): ...
|
||||
| ----------------------- `Super.method` defined here
|
||||
3 |
|
||||
4 | class Sub1(Super):
|
||||
|
|
||||
info: This violates the Liskov Substitution Principle
|
||||
info: rule `invalid-method-override` is enabled by default
|
||||
|
||||
```
|
||||
|
||||
```
|
||||
error[invalid-method-override]: Invalid override of method `method`
|
||||
--> src/mdtest_snippet.pyi:48:9
|
||||
|
|
||||
46 | # Some calls permitted by the superclass are now no longer allowed
|
||||
47 | # (x can no longer be passed positionally!)
|
||||
48 | def method(self, /, *, x): ... # error: [invalid-method-override]
|
||||
| ^^^^^^^^^^^^^^^^^^^^^ Definition is incompatible with `Super.method`
|
||||
49 |
|
||||
50 | class Sub15(Super):
|
||||
|
|
||||
::: src/mdtest_snippet.pyi:2:9
|
||||
|
|
||||
1 | class Super:
|
||||
2 | def method(self, x: int, /): ...
|
||||
| ----------------------- `Super.method` defined here
|
||||
3 |
|
||||
4 | class Sub1(Super):
|
||||
|
|
||||
info: This violates the Liskov Substitution Principle
|
||||
info: rule `invalid-method-override` is enabled by default
|
||||
|
||||
```
|
||||
|
||||
```
|
||||
error[invalid-method-override]: Invalid override of method `method`
|
||||
--> src/mdtest_snippet.pyi:53:9
|
||||
|
|
||||
51 | # Some calls permitted by the superclass are now no longer allowed
|
||||
52 | # (x can no longer be passed any integer -- it now requires a bool!)
|
||||
53 | def method(self, x: bool, /): ... # error: [invalid-method-override]
|
||||
| ^^^^^^^^^^^^^^^^^^^^^^^^ Definition is incompatible with `Super.method`
|
||||
54 |
|
||||
55 | class Super2:
|
||||
|
|
||||
::: src/mdtest_snippet.pyi:2:9
|
||||
|
|
||||
1 | class Super:
|
||||
2 | def method(self, x: int, /): ...
|
||||
| ----------------------- `Super.method` defined here
|
||||
3 |
|
||||
4 | class Sub1(Super):
|
||||
|
|
||||
info: This violates the Liskov Substitution Principle
|
||||
info: rule `invalid-method-override` is enabled by default
|
||||
|
||||
```
|
||||
|
||||
```
|
||||
error[invalid-method-override]: Invalid override of method `method2`
|
||||
--> src/mdtest_snippet.pyi:56:9
|
||||
|
|
||||
55 | class Super2:
|
||||
56 | def method2(self, x): ...
|
||||
| ---------------- `Super2.method2` defined here
|
||||
57 |
|
||||
58 | class Sub16(Super2):
|
||||
59 | def method2(self, x, /): ... # error: [invalid-method-override]
|
||||
| ^^^^^^^^^^^^^^^^^^^ Definition is incompatible with `Super2.method2`
|
||||
60 |
|
||||
61 | class Sub17(Super2):
|
||||
|
|
||||
info: This violates the Liskov Substitution Principle
|
||||
info: rule `invalid-method-override` is enabled by default
|
||||
|
||||
```
|
||||
|
||||
```
|
||||
error[invalid-method-override]: Invalid override of method `method2`
|
||||
--> src/mdtest_snippet.pyi:62:9
|
||||
|
|
||||
61 | class Sub17(Super2):
|
||||
62 | def method2(self, *, x): ... # error: [invalid-method-override]
|
||||
| ^^^^^^^^^^^^^^^^^^^ Definition is incompatible with `Super2.method2`
|
||||
63 |
|
||||
64 | class Super3:
|
||||
|
|
||||
::: src/mdtest_snippet.pyi:56:9
|
||||
|
|
||||
55 | class Super2:
|
||||
56 | def method2(self, x): ...
|
||||
| ---------------- `Super2.method2` defined here
|
||||
57 |
|
||||
58 | class Sub16(Super2):
|
||||
|
|
||||
info: This violates the Liskov Substitution Principle
|
||||
info: rule `invalid-method-override` is enabled by default
|
||||
|
||||
```
|
||||
|
||||
```
|
||||
error[invalid-method-override]: Invalid override of method `method3`
|
||||
--> src/mdtest_snippet.pyi:71:9
|
||||
|
|
||||
70 | class Sub19(Super3):
|
||||
71 | def method3(self, x, /): ... # error: [invalid-method-override]
|
||||
| ^^^^^^^^^^^^^^^^^^^ Definition is incompatible with `Super3.method3`
|
||||
72 |
|
||||
73 | class Super4:
|
||||
|
|
||||
::: src/mdtest_snippet.pyi:65:9
|
||||
|
|
||||
64 | class Super3:
|
||||
65 | def method3(self, *, x): ...
|
||||
| ------------------- `Super3.method3` defined here
|
||||
66 |
|
||||
67 | class Sub18(Super3):
|
||||
|
|
||||
info: This violates the Liskov Substitution Principle
|
||||
info: rule `invalid-method-override` is enabled by default
|
||||
|
||||
```
|
||||
|
||||
```
|
||||
error[invalid-method-override]: Invalid override of method `method`
|
||||
--> src/mdtest_snippet.pyi:80:9
|
||||
|
|
||||
79 | class Sub21(Super4):
|
||||
80 | def method(self, *args): ... # error: [invalid-method-override]
|
||||
| ^^^^^^^^^^^^^^^^^^^ Definition is incompatible with `Super4.method`
|
||||
81 |
|
||||
82 | class Sub22(Super4):
|
||||
|
|
||||
::: src/mdtest_snippet.pyi:74:9
|
||||
|
|
||||
73 | class Super4:
|
||||
74 | def method(self, *args: int, **kwargs: str): ...
|
||||
| --------------------------------------- `Super4.method` defined here
|
||||
75 |
|
||||
76 | class Sub20(Super4):
|
||||
|
|
||||
info: This violates the Liskov Substitution Principle
|
||||
info: rule `invalid-method-override` is enabled by default
|
||||
|
||||
```
|
||||
|
||||
```
|
||||
error[invalid-method-override]: Invalid override of method `method`
|
||||
--> src/mdtest_snippet.pyi:83:9
|
||||
|
|
||||
82 | class Sub22(Super4):
|
||||
83 | def method(self, **kwargs): ... # error: [invalid-method-override]
|
||||
| ^^^^^^^^^^^^^^^^^^^^^^ Definition is incompatible with `Super4.method`
|
||||
84 |
|
||||
85 | class Sub23(Super4):
|
||||
|
|
||||
::: src/mdtest_snippet.pyi:74:9
|
||||
|
|
||||
73 | class Super4:
|
||||
74 | def method(self, *args: int, **kwargs: str): ...
|
||||
| --------------------------------------- `Super4.method` defined here
|
||||
75 |
|
||||
76 | class Sub20(Super4):
|
||||
|
|
||||
info: This violates the Liskov Substitution Principle
|
||||
info: rule `invalid-method-override` is enabled by default
|
||||
|
||||
```
|
||||
|
||||
```
|
||||
error[invalid-method-override]: Invalid override of method `method`
|
||||
--> src/mdtest_snippet.pyi:86:9
|
||||
|
|
||||
85 | class Sub23(Super4):
|
||||
86 | def method(self, x, *args, y, **kwargs): ... # error: [invalid-method-override]
|
||||
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Definition is incompatible with `Super4.method`
|
||||
|
|
||||
::: src/mdtest_snippet.pyi:74:9
|
||||
|
|
||||
73 | class Super4:
|
||||
74 | def method(self, *args: int, **kwargs: str): ...
|
||||
| --------------------------------------- `Super4.method` defined here
|
||||
75 |
|
||||
76 | class Sub20(Super4):
|
||||
|
|
||||
info: This violates the Liskov Substitution Principle
|
||||
info: rule `invalid-method-override` is enabled by default
|
||||
|
||||
```
|
||||
@@ -0,0 +1,75 @@
|
||||
---
|
||||
source: crates/ty_test/src/lib.rs
|
||||
expression: snapshot
|
||||
---
|
||||
---
|
||||
mdtest name: liskov.md - The Liskov Substitution Principle - Method return types
|
||||
mdtest path: crates/ty_python_semantic/resources/mdtest/liskov.md
|
||||
---
|
||||
|
||||
# Python source files
|
||||
|
||||
## mdtest_snippet.pyi
|
||||
|
||||
```
|
||||
1 | class Super:
|
||||
2 | def method(self) -> int: ...
|
||||
3 |
|
||||
4 | class Sub1(Super):
|
||||
5 | def method(self) -> int: ... # fine
|
||||
6 |
|
||||
7 | class Sub2(Super):
|
||||
8 | def method(self) -> bool: ... # fine: `bool` is a subtype of `int`
|
||||
9 |
|
||||
10 | class Sub3(Super):
|
||||
11 | def method(self) -> object: ... # error: [invalid-method-override]
|
||||
12 |
|
||||
13 | class Sub4(Super):
|
||||
14 | def method(self) -> str: ... # error: [invalid-method-override]
|
||||
```
|
||||
|
||||
# Diagnostics
|
||||
|
||||
```
|
||||
error[invalid-method-override]: Invalid override of method `method`
|
||||
--> src/mdtest_snippet.pyi:11:9
|
||||
|
|
||||
10 | class Sub3(Super):
|
||||
11 | def method(self) -> object: ... # error: [invalid-method-override]
|
||||
| ^^^^^^^^^^^^^^^^^^^^^^ Definition is incompatible with `Super.method`
|
||||
12 |
|
||||
13 | class Sub4(Super):
|
||||
|
|
||||
::: src/mdtest_snippet.pyi:2:9
|
||||
|
|
||||
1 | class Super:
|
||||
2 | def method(self) -> int: ...
|
||||
| ------------------- `Super.method` defined here
|
||||
3 |
|
||||
4 | class Sub1(Super):
|
||||
|
|
||||
info: This violates the Liskov Substitution Principle
|
||||
info: rule `invalid-method-override` is enabled by default
|
||||
|
||||
```
|
||||
|
||||
```
|
||||
error[invalid-method-override]: Invalid override of method `method`
|
||||
--> src/mdtest_snippet.pyi:14:9
|
||||
|
|
||||
13 | class Sub4(Super):
|
||||
14 | def method(self) -> str: ... # error: [invalid-method-override]
|
||||
| ^^^^^^^^^^^^^^^^^^^ Definition is incompatible with `Super.method`
|
||||
|
|
||||
::: src/mdtest_snippet.pyi:2:9
|
||||
|
|
||||
1 | class Super:
|
||||
2 | def method(self) -> int: ...
|
||||
| ------------------- `Super.method` defined here
|
||||
3 |
|
||||
4 | class Sub1(Super):
|
||||
|
|
||||
info: This violates the Liskov Substitution Principle
|
||||
info: rule `invalid-method-override` is enabled by default
|
||||
|
||||
```
|
||||
@@ -0,0 +1,116 @@
|
||||
---
|
||||
source: crates/ty_test/src/lib.rs
|
||||
expression: snapshot
|
||||
---
|
||||
---
|
||||
mdtest name: liskov.md - The Liskov Substitution Principle - Synthesized methods
|
||||
mdtest path: crates/ty_python_semantic/resources/mdtest/liskov.md
|
||||
---
|
||||
|
||||
# Python source files
|
||||
|
||||
## mdtest_snippet.pyi
|
||||
|
||||
```
|
||||
1 | from dataclasses import dataclass
|
||||
2 | from typing import NamedTuple
|
||||
3 |
|
||||
4 | @dataclass(order=True)
|
||||
5 | class Foo:
|
||||
6 | x: int
|
||||
7 |
|
||||
8 | class Bar(Foo):
|
||||
9 | def __lt__(self, other: Bar) -> bool: ... # error: [invalid-method-override]
|
||||
10 |
|
||||
11 | # TODO: specifying `order=True` on the subclass means that a `__lt__` method is
|
||||
12 | # generated that is incompatible with the generated `__lt__` method on the superclass.
|
||||
13 | # We could consider detecting this and emitting a diagnostic, though maybe it shouldn't
|
||||
14 | # be `invalid-method-override` since we'd emit it on the class definition rather than
|
||||
15 | # on any method definition. Note also that no other type checker complains about this
|
||||
16 | # as of 2025-11-21.
|
||||
17 | @dataclass(order=True)
|
||||
18 | class Bar2(Foo):
|
||||
19 | y: str
|
||||
20 |
|
||||
21 | # TODO: Although this class does not override any methods of `Foo`, the design of the
|
||||
22 | # `order=True` stdlib dataclasses feature itself arguably violates the Liskov Substitution
|
||||
23 | # Principle! Instances of `Bar3` cannot be substituted wherever an instance of `Foo` is
|
||||
24 | # expected, because the generated `__lt__` method on `Foo` raises an error unless the r.h.s.
|
||||
25 | # and `l.h.s.` have exactly the same `__class__` (it does not permit instances of `Foo` to
|
||||
26 | # be compared with instances of subclasses of `Foo`).
|
||||
27 | #
|
||||
28 | # Many users would probably like their type checkers to alert them to cases where instances
|
||||
29 | # of subclasses cannot be substituted for instances of superclasses, as this violates many
|
||||
30 | # assumptions a type checker will make and makes it likely that a type checker will fail to
|
||||
31 | # catch type errors elsewhere in the user's code. We could therefore consider treating all
|
||||
32 | # `order=True` dataclasses as implicitly `@final` in order to enforce soundness. However,
|
||||
33 | # this probably shouldn't be reported with the same error code as Liskov violations, since
|
||||
34 | # the error does not stem from any method signatures written by the user. The example is
|
||||
35 | # only included here for completeness.
|
||||
36 | #
|
||||
37 | # Note that no other type checker catches this error as of 2025-11-21.
|
||||
38 | class Bar3(Foo): ...
|
||||
39 |
|
||||
40 | class Eggs:
|
||||
41 | def __lt__(self, other: Eggs) -> bool: ...
|
||||
42 |
|
||||
43 | # TODO: the generated `Ham.__lt__` method here incompatibly overrides `Eggs.__lt__`.
|
||||
44 | # We could consider emitting a diagnostic here. As of 2025-11-21, mypy reports a
|
||||
45 | # diagnostic here but pyright and pyrefly do not.
|
||||
46 | @dataclass(order=True)
|
||||
47 | class Ham(Eggs):
|
||||
48 | x: int
|
||||
49 |
|
||||
50 | class Baz(NamedTuple):
|
||||
51 | x: int
|
||||
52 |
|
||||
53 | class Spam(Baz):
|
||||
54 | def _asdict(self) -> tuple[int, ...]: ... # error: [invalid-method-override]
|
||||
```
|
||||
|
||||
# Diagnostics
|
||||
|
||||
```
|
||||
error[invalid-method-override]: Invalid override of method `__lt__`
|
||||
--> src/mdtest_snippet.pyi:9:9
|
||||
|
|
||||
8 | class Bar(Foo):
|
||||
9 | def __lt__(self, other: Bar) -> bool: ... # error: [invalid-method-override]
|
||||
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Definition is incompatible with `Foo.__lt__`
|
||||
10 |
|
||||
11 | # TODO: specifying `order=True` on the subclass means that a `__lt__` method is
|
||||
|
|
||||
info: This violates the Liskov Substitution Principle
|
||||
info: `Foo.__lt__` is a generated method created because `Foo` is a dataclass
|
||||
--> src/mdtest_snippet.pyi:5:7
|
||||
|
|
||||
4 | @dataclass(order=True)
|
||||
5 | class Foo:
|
||||
| ^^^ Definition of `Foo`
|
||||
6 | x: int
|
||||
|
|
||||
info: rule `invalid-method-override` is enabled by default
|
||||
|
||||
```
|
||||
|
||||
```
|
||||
error[invalid-method-override]: Invalid override of method `_asdict`
|
||||
--> src/mdtest_snippet.pyi:54:9
|
||||
|
|
||||
53 | class Spam(Baz):
|
||||
54 | def _asdict(self) -> tuple[int, ...]: ... # error: [invalid-method-override]
|
||||
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Definition is incompatible with `Baz._asdict`
|
||||
|
|
||||
info: This violates the Liskov Substitution Principle
|
||||
info: `Baz._asdict` is a generated method created because `Baz` inherits from `typing.NamedTuple`
|
||||
--> src/mdtest_snippet.pyi:50:7
|
||||
|
|
||||
48 | x: int
|
||||
49 |
|
||||
50 | class Baz(NamedTuple):
|
||||
| ^^^^^^^^^^^^^^^ Definition of `Baz`
|
||||
51 | x: int
|
||||
|
|
||||
info: rule `invalid-method-override` is enabled by default
|
||||
|
||||
```
|
||||
@@ -0,0 +1,192 @@
|
||||
---
|
||||
source: crates/ty_test/src/lib.rs
|
||||
expression: snapshot
|
||||
---
|
||||
---
|
||||
mdtest name: liskov.md - The Liskov Substitution Principle - The entire class hierarchy is checked
|
||||
mdtest path: crates/ty_python_semantic/resources/mdtest/liskov.md
|
||||
---
|
||||
|
||||
# Python source files
|
||||
|
||||
## stub.pyi
|
||||
|
||||
```
|
||||
1 | from typing import Any
|
||||
2 |
|
||||
3 | class Grandparent:
|
||||
4 | def method(self, x: int) -> None: ...
|
||||
5 |
|
||||
6 | class Parent(Grandparent):
|
||||
7 | def method(self, x: str) -> None: ... # error: [invalid-method-override]
|
||||
8 |
|
||||
9 | class Child(Parent):
|
||||
10 | # compatible with the signature of `Parent.method`, but not with `Grandparent.method`:
|
||||
11 | def method(self, x: str) -> None: ... # error: [invalid-method-override]
|
||||
12 |
|
||||
13 | class OtherChild(Parent):
|
||||
14 | # compatible with the signature of `Grandparent.method`, but not with `Parent.method`:
|
||||
15 | def method(self, x: int) -> None: ... # error: [invalid-method-override]
|
||||
16 |
|
||||
17 | class GradualParent(Grandparent):
|
||||
18 | def method(self, x: Any) -> None: ...
|
||||
19 |
|
||||
20 | class ThirdChild(GradualParent):
|
||||
21 | # `GradualParent.method` is compatible with the signature of `Grandparent.method`,
|
||||
22 | # and `ThirdChild.method` is compatible with the signature of `GradualParent.method`,
|
||||
23 | # but `ThirdChild.method` is not compatible with the signature of `Grandparent.method`
|
||||
24 | def method(self, x: str) -> None: ... # error: [invalid-method-override]
|
||||
```
|
||||
|
||||
## other_stub.pyi
|
||||
|
||||
```
|
||||
1 | class A:
|
||||
2 | def get(self, default): ...
|
||||
3 |
|
||||
4 | class B(A):
|
||||
5 | def get(self, default, /): ... # error: [invalid-method-override]
|
||||
6 |
|
||||
7 | get = 56
|
||||
8 |
|
||||
9 | class C(B):
|
||||
10 | # `get` appears in the symbol table of `C`,
|
||||
11 | # but that doesn't confuse our diagnostic...
|
||||
12 | foo = get
|
||||
13 |
|
||||
14 | class D(C):
|
||||
15 | # compatible with `C.get` and `B.get`, but not with `A.get`
|
||||
16 | def get(self, my_default): ... # error: [invalid-method-override]
|
||||
```
|
||||
|
||||
# Diagnostics
|
||||
|
||||
```
|
||||
error[invalid-method-override]: Invalid override of method `method`
|
||||
--> src/stub.pyi:4:9
|
||||
|
|
||||
3 | class Grandparent:
|
||||
4 | def method(self, x: int) -> None: ...
|
||||
| ---------------------------- `Grandparent.method` defined here
|
||||
5 |
|
||||
6 | class Parent(Grandparent):
|
||||
7 | def method(self, x: str) -> None: ... # error: [invalid-method-override]
|
||||
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Definition is incompatible with `Grandparent.method`
|
||||
8 |
|
||||
9 | class Child(Parent):
|
||||
|
|
||||
info: This violates the Liskov Substitution Principle
|
||||
info: rule `invalid-method-override` is enabled by default
|
||||
|
||||
```
|
||||
|
||||
```
|
||||
error[invalid-method-override]: Invalid override of method `method`
|
||||
--> src/stub.pyi:11:9
|
||||
|
|
||||
9 | class Child(Parent):
|
||||
10 | # compatible with the signature of `Parent.method`, but not with `Grandparent.method`:
|
||||
11 | def method(self, x: str) -> None: ... # error: [invalid-method-override]
|
||||
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Definition is incompatible with `Grandparent.method`
|
||||
12 |
|
||||
13 | class OtherChild(Parent):
|
||||
|
|
||||
::: src/stub.pyi:4:9
|
||||
|
|
||||
3 | class Grandparent:
|
||||
4 | def method(self, x: int) -> None: ...
|
||||
| ---------------------------- `Grandparent.method` defined here
|
||||
5 |
|
||||
6 | class Parent(Grandparent):
|
||||
|
|
||||
info: This violates the Liskov Substitution Principle
|
||||
info: rule `invalid-method-override` is enabled by default
|
||||
|
||||
```
|
||||
|
||||
```
|
||||
error[invalid-method-override]: Invalid override of method `method`
|
||||
--> src/stub.pyi:15:9
|
||||
|
|
||||
13 | class OtherChild(Parent):
|
||||
14 | # compatible with the signature of `Grandparent.method`, but not with `Parent.method`:
|
||||
15 | def method(self, x: int) -> None: ... # error: [invalid-method-override]
|
||||
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Definition is incompatible with `Parent.method`
|
||||
16 |
|
||||
17 | class GradualParent(Grandparent):
|
||||
|
|
||||
::: src/stub.pyi:7:9
|
||||
|
|
||||
6 | class Parent(Grandparent):
|
||||
7 | def method(self, x: str) -> None: ... # error: [invalid-method-override]
|
||||
| ---------------------------- `Parent.method` defined here
|
||||
8 |
|
||||
9 | class Child(Parent):
|
||||
|
|
||||
info: This violates the Liskov Substitution Principle
|
||||
info: rule `invalid-method-override` is enabled by default
|
||||
|
||||
```
|
||||
|
||||
```
|
||||
error[invalid-method-override]: Invalid override of method `method`
|
||||
--> src/stub.pyi:24:9
|
||||
|
|
||||
22 | # and `ThirdChild.method` is compatible with the signature of `GradualParent.method`,
|
||||
23 | # but `ThirdChild.method` is not compatible with the signature of `Grandparent.method`
|
||||
24 | def method(self, x: str) -> None: ... # error: [invalid-method-override]
|
||||
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Definition is incompatible with `Grandparent.method`
|
||||
|
|
||||
::: src/stub.pyi:4:9
|
||||
|
|
||||
3 | class Grandparent:
|
||||
4 | def method(self, x: int) -> None: ...
|
||||
| ---------------------------- `Grandparent.method` defined here
|
||||
5 |
|
||||
6 | class Parent(Grandparent):
|
||||
|
|
||||
info: This violates the Liskov Substitution Principle
|
||||
info: rule `invalid-method-override` is enabled by default
|
||||
|
||||
```
|
||||
|
||||
```
|
||||
error[invalid-method-override]: Invalid override of method `get`
|
||||
--> src/other_stub.pyi:2:9
|
||||
|
|
||||
1 | class A:
|
||||
2 | def get(self, default): ...
|
||||
| ------------------ `A.get` defined here
|
||||
3 |
|
||||
4 | class B(A):
|
||||
5 | def get(self, default, /): ... # error: [invalid-method-override]
|
||||
| ^^^^^^^^^^^^^^^^^^^^^ Definition is incompatible with `A.get`
|
||||
6 |
|
||||
7 | get = 56
|
||||
|
|
||||
info: This violates the Liskov Substitution Principle
|
||||
info: rule `invalid-method-override` is enabled by default
|
||||
|
||||
```
|
||||
|
||||
```
|
||||
error[invalid-method-override]: Invalid override of method `get`
|
||||
--> src/other_stub.pyi:16:9
|
||||
|
|
||||
14 | class D(C):
|
||||
15 | # compatible with `C.get` and `B.get`, but not with `A.get`
|
||||
16 | def get(self, my_default): ... # error: [invalid-method-override]
|
||||
| ^^^^^^^^^^^^^^^^^^^^^ Definition is incompatible with `A.get`
|
||||
|
|
||||
::: src/other_stub.pyi:2:9
|
||||
|
|
||||
1 | class A:
|
||||
2 | def get(self, default): ...
|
||||
| ------------------ `A.get` defined here
|
||||
3 |
|
||||
4 | class B(A):
|
||||
|
|
||||
info: This violates the Liskov Substitution Principle
|
||||
info: rule `invalid-method-override` is enabled by default
|
||||
|
||||
```
|
||||
@@ -12,27 +12,59 @@ mdtest path: crates/ty_python_semantic/resources/mdtest/comparison/tuples.md
|
||||
## mdtest_snippet.py
|
||||
|
||||
```
|
||||
1 | class NotBoolable:
|
||||
2 | __bool__: None = None
|
||||
3 |
|
||||
4 | class A:
|
||||
5 | def __eq__(self, other) -> NotBoolable:
|
||||
6 | return NotBoolable()
|
||||
7 |
|
||||
8 | # error: [unsupported-bool-conversion]
|
||||
9 | (A(),) == (A(),)
|
||||
1 | class NotBoolable:
|
||||
2 | __bool__: None = None
|
||||
3 |
|
||||
4 | class A:
|
||||
5 | # error: [invalid-method-override]
|
||||
6 | def __eq__(self, other) -> NotBoolable:
|
||||
7 | return NotBoolable()
|
||||
8 |
|
||||
9 | # error: [unsupported-bool-conversion]
|
||||
10 | (A(),) == (A(),)
|
||||
```
|
||||
|
||||
# Diagnostics
|
||||
|
||||
```
|
||||
error[invalid-method-override]: Invalid override of method `__eq__`
|
||||
--> src/mdtest_snippet.py:6:9
|
||||
|
|
||||
4 | class A:
|
||||
5 | # error: [invalid-method-override]
|
||||
6 | def __eq__(self, other) -> NotBoolable:
|
||||
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Definition is incompatible with `object.__eq__`
|
||||
7 | return NotBoolable()
|
||||
|
|
||||
::: stdlib/builtins.pyi:142:9
|
||||
|
|
||||
140 | def __setattr__(self, name: str, value: Any, /) -> None: ...
|
||||
141 | def __delattr__(self, name: str, /) -> None: ...
|
||||
142 | def __eq__(self, value: object, /) -> bool: ...
|
||||
| -------------------------------------- `object.__eq__` defined here
|
||||
143 | def __ne__(self, value: object, /) -> bool: ...
|
||||
144 | def __str__(self) -> str: ... # noqa: Y029
|
||||
|
|
||||
info: This violates the Liskov Substitution Principle
|
||||
help: It is recommended for `__eq__` to work with arbitrary objects, for example:
|
||||
help
|
||||
help: def __eq__(self, other: object) -> bool:
|
||||
help: if not isinstance(other, A):
|
||||
help: return False
|
||||
help: return <logic to compare two `A` instances>
|
||||
help
|
||||
info: rule `invalid-method-override` is enabled by default
|
||||
|
||||
```
|
||||
|
||||
```
|
||||
error[unsupported-bool-conversion]: Boolean conversion is unsupported for type `NotBoolable`
|
||||
--> src/mdtest_snippet.py:9:1
|
||||
|
|
||||
8 | # error: [unsupported-bool-conversion]
|
||||
9 | (A(),) == (A(),)
|
||||
| ^^^^^^^^^^^^^^^^
|
||||
|
|
||||
--> src/mdtest_snippet.py:10:1
|
||||
|
|
||||
9 | # error: [unsupported-bool-conversion]
|
||||
10 | (A(),) == (A(),)
|
||||
| ^^^^^^^^^^^^^^^^
|
||||
|
|
||||
info: `__bool__` on `NotBoolable` must be callable
|
||||
info: rule `unsupported-bool-conversion` is enabled by default
|
||||
|
||||
|
||||
@@ -737,6 +737,10 @@ impl DefinitionKind<'_> {
|
||||
matches!(self, DefinitionKind::Assignment(_))
|
||||
}
|
||||
|
||||
pub(crate) const fn is_function_def(&self) -> bool {
|
||||
matches!(self, DefinitionKind::Function(_))
|
||||
}
|
||||
|
||||
/// Returns the [`TextRange`] of the definition target.
|
||||
///
|
||||
/// A definition target would mainly be the node representing the place being defined i.e.,
|
||||
|
||||
@@ -95,6 +95,7 @@ mod generics;
|
||||
pub mod ide_support;
|
||||
mod infer;
|
||||
mod instance;
|
||||
mod liskov;
|
||||
mod member;
|
||||
mod mro;
|
||||
mod narrow;
|
||||
@@ -1104,6 +1105,13 @@ impl<'db> Type<'db> {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) const fn as_bound_method(self) -> Option<BoundMethodType<'db>> {
|
||||
match self {
|
||||
Type::BoundMethod(bound_method) => Some(bound_method),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[track_caller]
|
||||
pub(crate) const fn expect_class_literal(self) -> ClassLiteral<'db> {
|
||||
self.as_class_literal()
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use std::cell::RefCell;
|
||||
use std::fmt::Write;
|
||||
use std::sync::{LazyLock, Mutex};
|
||||
|
||||
use super::TypeVarVariance;
|
||||
@@ -10,7 +11,7 @@ use super::{
|
||||
use crate::module_resolver::KnownModule;
|
||||
use crate::place::TypeOrigin;
|
||||
use crate::semantic_index::definition::{Definition, DefinitionState};
|
||||
use crate::semantic_index::scope::{NodeWithScopeKind, Scope};
|
||||
use crate::semantic_index::scope::{NodeWithScopeKind, Scope, ScopeKind};
|
||||
use crate::semantic_index::symbol::Symbol;
|
||||
use crate::semantic_index::{
|
||||
DeclarationWithConstraint, SemanticIndex, attribute_declarations, attribute_scopes,
|
||||
@@ -3649,6 +3650,10 @@ impl<'db> ClassLiteral<'db> {
|
||||
.unwrap_or_else(|| class_name.end()),
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn qualified_name(self, db: &'db dyn Db) -> QualifiedClassName<'db> {
|
||||
QualifiedClassName { db, class: self }
|
||||
}
|
||||
}
|
||||
|
||||
impl<'db> From<ClassLiteral<'db>> for Type<'db> {
|
||||
@@ -3783,6 +3788,74 @@ impl<'db> VarianceInferable<'db> for ClassLiteral<'db> {
|
||||
}
|
||||
}
|
||||
|
||||
// N.B. It would be incorrect to derive `Eq`, `PartialEq`, or `Hash` for this struct,
|
||||
// because two `QualifiedClassName` instances might refer to different classes but
|
||||
// have the same components. You'd expect them to compare equal, but they'd compare
|
||||
// unequal if `PartialEq`/`Eq` were naively derived.
|
||||
#[derive(Clone, Copy)]
|
||||
pub(super) struct QualifiedClassName<'db> {
|
||||
db: &'db dyn Db,
|
||||
class: ClassLiteral<'db>,
|
||||
}
|
||||
|
||||
impl QualifiedClassName<'_> {
|
||||
/// Returns the components of the qualified name of this class, excluding this class itself.
|
||||
///
|
||||
/// For example, calling this method on a class `C` in the module `a.b` would return
|
||||
/// `["a", "b"]`. Calling this method on a class `D` inside the namespace of a method
|
||||
/// `m` inside the namespace of a class `C` in the module `a.b` would return
|
||||
/// `["a", "b", "C", "<locals of function 'm'>"]`.
|
||||
pub(super) fn components_excluding_self(&self) -> Vec<String> {
|
||||
let body_scope = self.class.body_scope(self.db);
|
||||
let file = body_scope.file(self.db);
|
||||
let module_ast = parsed_module(self.db, file).load(self.db);
|
||||
let index = semantic_index(self.db, file);
|
||||
let file_scope_id = body_scope.file_scope_id(self.db);
|
||||
|
||||
let mut name_parts = vec![];
|
||||
|
||||
// Skips itself
|
||||
for (_, ancestor_scope) in index.ancestor_scopes(file_scope_id).skip(1) {
|
||||
let node = ancestor_scope.node();
|
||||
|
||||
match ancestor_scope.kind() {
|
||||
ScopeKind::Class => {
|
||||
if let Some(class_def) = node.as_class() {
|
||||
name_parts.push(class_def.node(&module_ast).name.as_str().to_string());
|
||||
}
|
||||
}
|
||||
ScopeKind::Function => {
|
||||
if let Some(function_def) = node.as_function() {
|
||||
name_parts.push(format!(
|
||||
"<locals of function '{}'>",
|
||||
function_def.node(&module_ast).name.as_str()
|
||||
));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(module) = file_to_module(self.db, file) {
|
||||
let module_name = module.name(self.db);
|
||||
name_parts.push(module_name.as_str().to_string());
|
||||
}
|
||||
|
||||
name_parts.reverse();
|
||||
name_parts
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for QualifiedClassName<'_> {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
for parent in self.components_excluding_self() {
|
||||
f.write_str(&parent)?;
|
||||
f.write_char('.')?;
|
||||
}
|
||||
f.write_str(self.class.name(self.db))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, get_size2::GetSize)]
|
||||
pub(super) enum InheritanceCycle {
|
||||
/// The class is cyclically defined and is a participant in the cycle.
|
||||
|
||||
@@ -10,12 +10,13 @@ use crate::diagnostic::format_enumeration;
|
||||
use crate::lint::{Level, LintRegistryBuilder, LintStatus};
|
||||
use crate::semantic_index::definition::{Definition, DefinitionKind};
|
||||
use crate::semantic_index::place::{PlaceTable, ScopedPlaceId};
|
||||
use crate::semantic_index::{global_scope, place_table};
|
||||
use crate::semantic_index::{global_scope, place_table, use_def_map};
|
||||
use crate::suppression::FileSuppressionId;
|
||||
use crate::types::KnownInstanceType;
|
||||
use crate::types::call::CallError;
|
||||
use crate::types::class::{DisjointBase, DisjointBaseKind, Field};
|
||||
use crate::types::function::KnownFunction;
|
||||
use crate::types::function::{FunctionType, KnownFunction};
|
||||
use crate::types::liskov::{MethodKind, SynthesizedMethodKind};
|
||||
use crate::types::string_annotation::{
|
||||
BYTE_STRING_TYPE_ANNOTATION, ESCAPE_CHARACTER_IN_FORWARD_ANNOTATION, FSTRING_TYPE_ANNOTATION,
|
||||
IMPLICIT_CONCATENATED_STRING_TYPE_ANNOTATION, INVALID_SYNTAX_IN_FORWARD_ANNOTATION,
|
||||
@@ -28,15 +29,18 @@ use crate::types::{
|
||||
};
|
||||
use crate::{Db, DisplaySettings, FxIndexMap, Module, ModuleName, Program, declare_lint};
|
||||
use itertools::Itertools;
|
||||
use ruff_db::diagnostic::{Annotation, Diagnostic, Span, SubDiagnostic, SubDiagnosticSeverity};
|
||||
use ruff_db::source::source_text;
|
||||
use ruff_db::{
|
||||
diagnostic::{Annotation, Diagnostic, Span, SubDiagnostic, SubDiagnosticSeverity},
|
||||
parsed::parsed_module,
|
||||
source::source_text,
|
||||
};
|
||||
use ruff_python_ast::name::Name;
|
||||
use ruff_python_ast::parenthesize::parentheses_iterator;
|
||||
use ruff_python_ast::{self as ast, AnyNodeRef};
|
||||
use ruff_python_trivia::CommentRanges;
|
||||
use ruff_text_size::{Ranged, TextRange};
|
||||
use rustc_hash::FxHashSet;
|
||||
use std::fmt::Formatter;
|
||||
use std::fmt::{self, Formatter};
|
||||
|
||||
/// Registers all known type check lints.
|
||||
pub(crate) fn register_lints(registry: &mut LintRegistryBuilder) {
|
||||
@@ -108,6 +112,7 @@ pub(crate) fn register_lints(registry: &mut LintRegistryBuilder) {
|
||||
registry.register_lint(&REDUNDANT_CAST);
|
||||
registry.register_lint(&UNRESOLVED_GLOBAL);
|
||||
registry.register_lint(&MISSING_TYPED_DICT_KEY);
|
||||
registry.register_lint(&INVALID_METHOD_OVERRIDE);
|
||||
|
||||
// String annotations
|
||||
registry.register_lint(&BYTE_STRING_TYPE_ANNOTATION);
|
||||
@@ -1934,6 +1939,84 @@ declare_lint! {
|
||||
}
|
||||
}
|
||||
|
||||
declare_lint! {
|
||||
/// ## What it does
|
||||
/// Detects method overrides that violate the [Liskov Substitution Principle] ("LSP").
|
||||
///
|
||||
/// The LSP states that an instance of a subtype should be substitutable for an instance of its supertype.
|
||||
/// Applied to Python, this means:
|
||||
/// 1. All argument combinations a superclass method accepts
|
||||
/// must also be accepted by an overriding subclass method.
|
||||
/// 2. The return type of an overriding subclass method must be a subtype
|
||||
/// of the return type of the superclass method.
|
||||
///
|
||||
/// ## Why is this bad?
|
||||
/// Violating the Liskov Substitution Principle will lead to many of ty's assumptions and
|
||||
/// inferences being incorrect, which will mean that it will fail to catch many possible
|
||||
/// type errors in your code.
|
||||
///
|
||||
/// ## Example
|
||||
/// ```python
|
||||
/// class Super:
|
||||
/// def method(self, x) -> int:
|
||||
/// return 42
|
||||
///
|
||||
/// class Sub(Super):
|
||||
/// # Liskov violation: `str` is not a subtype of `int`,
|
||||
/// # but the supertype method promises to return an `int`.
|
||||
/// def method(self, x) -> str: # error: [invalid-override]
|
||||
/// return "foo"
|
||||
///
|
||||
/// def accepts_super(s: Super) -> int:
|
||||
/// return s.method(x=42)
|
||||
///
|
||||
/// accepts_super(Sub()) # The result of this call is a string, but ty will infer
|
||||
/// # it to be an `int` due to the violation of the Liskov Substitution Principle.
|
||||
///
|
||||
/// class Sub2(Super):
|
||||
/// # Liskov violation: the superclass method can be called with a `x=`
|
||||
/// # keyword argument, but the subclass method does not accept it.
|
||||
/// def method(self, y) -> int: # error: [invalid-override]
|
||||
/// return 42
|
||||
///
|
||||
/// accepts_super(Sub2()) # TypeError at runtime: method() got an unexpected keyword argument 'x'
|
||||
/// # ty cannot catch this error due to the violation of the Liskov Substitution Principle.
|
||||
/// ```
|
||||
///
|
||||
/// ## Common issues
|
||||
///
|
||||
/// ### Why does ty complain about my `__eq__` method?
|
||||
///
|
||||
/// `__eq__` and `__ne__` methods in Python are generally expected to accept arbitrary
|
||||
/// objects as their second argument, for example:
|
||||
///
|
||||
/// ```python
|
||||
/// class A:
|
||||
/// x: int
|
||||
///
|
||||
/// def __eq__(self, other: object) -> bool:
|
||||
/// # gracefully handle an object of an unexpected type
|
||||
/// # without raising an exception
|
||||
/// if not isinstance(other, A):
|
||||
/// return False
|
||||
/// return self.x == other.x
|
||||
/// ```
|
||||
///
|
||||
/// If `A.__eq__` here were annotated as only accepting `A` instances for its second argument,
|
||||
/// it would imply that you wouldn't be able to use `==` between instances of `A` and
|
||||
/// instances of unrelated classes without an exception possibly being raised. While some
|
||||
/// classes in Python do indeed behave this way, the strongly held convention is that it should
|
||||
/// be avoided wherever possible. As part of this check, therefore, ty enforces that `__eq__`
|
||||
/// and `__ne__` methods accept `object` as their second argument.
|
||||
///
|
||||
/// [Liskov Substitution Principle]: https://en.wikipedia.org/wiki/Liskov_substitution_principle
|
||||
pub(crate) static INVALID_METHOD_OVERRIDE = {
|
||||
summary: "detects method definitions that violate the Liskov Substitution Principle",
|
||||
status: LintStatus::stable("0.0.1-alpha.20"),
|
||||
default_level: Level::Error,
|
||||
}
|
||||
}
|
||||
|
||||
/// A collection of type check diagnostics.
|
||||
#[derive(Default, Eq, PartialEq, get_size2::GetSize)]
|
||||
pub struct TypeCheckDiagnostics {
|
||||
@@ -3372,6 +3455,173 @@ pub(crate) fn report_rebound_typevar<'db>(
|
||||
}
|
||||
}
|
||||
|
||||
// I tried refactoring this function to placate Clippy,
|
||||
// but it did not improve readability! -- AW.
|
||||
#[expect(clippy::too_many_arguments)]
|
||||
pub(super) fn report_invalid_method_override<'db>(
|
||||
context: &InferContext<'db, '_>,
|
||||
member: &str,
|
||||
subclass: ClassType<'db>,
|
||||
subclass_definition: Definition<'db>,
|
||||
subclass_function: FunctionType<'db>,
|
||||
superclass: ClassType<'db>,
|
||||
superclass_type: Type<'db>,
|
||||
superclass_method_kind: MethodKind,
|
||||
) {
|
||||
let db = context.db();
|
||||
|
||||
let signature_span = |function: FunctionType<'db>| {
|
||||
function
|
||||
.literal(db)
|
||||
.last_definition(db)
|
||||
.spans(db)
|
||||
.map(|spans| spans.signature)
|
||||
};
|
||||
|
||||
let subclass_definition_kind = subclass_definition.kind(db);
|
||||
let subclass_definition_signature_span = signature_span(subclass_function);
|
||||
|
||||
// If the function was originally defined elsewhere and simply assigned
|
||||
// in the body of the class here, we cannot use the range associated with the `FunctionType`
|
||||
let diagnostic_range = if subclass_definition_kind.is_function_def() {
|
||||
subclass_definition_signature_span
|
||||
.as_ref()
|
||||
.and_then(Span::range)
|
||||
.unwrap_or_else(|| {
|
||||
subclass_function
|
||||
.node(db, context.file(), context.module())
|
||||
.range
|
||||
})
|
||||
} else {
|
||||
subclass_definition.full_range(db, context.module()).range()
|
||||
};
|
||||
|
||||
let class_name = subclass.name(db);
|
||||
let superclass_name = superclass.name(db);
|
||||
|
||||
let overridden_method = if class_name == superclass_name {
|
||||
format!(
|
||||
"{superclass}.{member}",
|
||||
superclass = superclass.class_literal(db).0.qualified_name(db),
|
||||
)
|
||||
} else {
|
||||
format!("{superclass_name}.{member}")
|
||||
};
|
||||
|
||||
let Some(builder) = context.report_lint(&INVALID_METHOD_OVERRIDE, diagnostic_range) else {
|
||||
return;
|
||||
};
|
||||
|
||||
let mut diagnostic =
|
||||
builder.into_diagnostic(format_args!("Invalid override of method `{member}`"));
|
||||
|
||||
diagnostic.set_primary_message(format_args!(
|
||||
"Definition is incompatible with `{overridden_method}`"
|
||||
));
|
||||
|
||||
diagnostic.info("This violates the Liskov Substitution Principle");
|
||||
|
||||
if !subclass_definition_kind.is_function_def()
|
||||
&& let Some(span) = subclass_definition_signature_span
|
||||
{
|
||||
diagnostic.annotate(
|
||||
Annotation::secondary(span)
|
||||
.message(format_args!("Signature of `{class_name}.{member}`")),
|
||||
);
|
||||
}
|
||||
|
||||
let superclass_scope = superclass.class_literal(db).0.body_scope(db);
|
||||
|
||||
match superclass_method_kind {
|
||||
MethodKind::NotSynthesized => {
|
||||
if let Some(superclass_symbol) = place_table(db, superclass_scope).symbol_id(member)
|
||||
&& let Some(binding) = use_def_map(db, superclass_scope)
|
||||
.end_of_scope_bindings(ScopedPlaceId::Symbol(superclass_symbol))
|
||||
.next()
|
||||
&& let Some(definition) = binding.binding.definition()
|
||||
{
|
||||
let definition_span = Span::from(
|
||||
definition
|
||||
.full_range(db, &parsed_module(db, superclass_scope.file(db)).load(db)),
|
||||
);
|
||||
|
||||
let superclass_function_span = superclass_type
|
||||
.as_bound_method()
|
||||
.and_then(|method| signature_span(method.function(db)));
|
||||
|
||||
let superclass_definition_kind = definition.kind(db);
|
||||
|
||||
let secondary_span = if superclass_definition_kind.is_function_def()
|
||||
&& let Some(function_span) = superclass_function_span.clone()
|
||||
{
|
||||
function_span
|
||||
} else {
|
||||
definition_span
|
||||
};
|
||||
|
||||
diagnostic.annotate(
|
||||
Annotation::secondary(secondary_span.clone())
|
||||
.message(format_args!("`{overridden_method}` defined here")),
|
||||
);
|
||||
|
||||
if !superclass_definition_kind.is_function_def()
|
||||
&& let Some(function_span) = superclass_function_span
|
||||
&& function_span != secondary_span
|
||||
{
|
||||
diagnostic.annotate(
|
||||
Annotation::secondary(function_span)
|
||||
.message(format_args!("Signature of `{overridden_method}`")),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
MethodKind::Synthesized(synthesized_kind) => {
|
||||
let make_sub =
|
||||
|message: fmt::Arguments| SubDiagnostic::new(SubDiagnosticSeverity::Info, message);
|
||||
|
||||
let mut sub = match synthesized_kind {
|
||||
SynthesizedMethodKind::Dataclass => make_sub(format_args!(
|
||||
"`{overridden_method}` is a generated method created because \
|
||||
`{superclass_name}` is a dataclass"
|
||||
)),
|
||||
SynthesizedMethodKind::NamedTuple => make_sub(format_args!(
|
||||
"`{overridden_method}` is a generated method created because \
|
||||
`{superclass_name}` inherits from `typing.NamedTuple`"
|
||||
)),
|
||||
SynthesizedMethodKind::TypedDict => make_sub(format_args!(
|
||||
"`{overridden_method}` is a generated method created because \
|
||||
`{superclass_name}` is a `TypedDict`"
|
||||
)),
|
||||
};
|
||||
|
||||
sub.annotate(
|
||||
Annotation::primary(superclass.header_span(db))
|
||||
.message(format_args!("Definition of `{superclass_name}`")),
|
||||
);
|
||||
diagnostic.sub(sub);
|
||||
}
|
||||
}
|
||||
|
||||
if superclass.is_object(db) && matches!(member, "__eq__" | "__ne__") {
|
||||
// Inspired by mypy's subdiagnostic at <https://github.com/python/mypy/blob/1b6ebb17b7fe64488a7b3c3b4b0187bb14fe331b/mypy/messages.py#L1307-L1318>
|
||||
let eq_subdiagnostics = [
|
||||
format_args!(
|
||||
"It is recommended for `{member}` to work with arbitrary objects, for example:",
|
||||
),
|
||||
format_args!(""),
|
||||
format_args!(" def {member}(self, other: object) -> bool:",),
|
||||
format_args!(" if not isinstance(other, {class_name}):",),
|
||||
format_args!(" return False"),
|
||||
format_args!(" return <logic to compare two `{class_name}` instances>"),
|
||||
format_args!(""),
|
||||
];
|
||||
|
||||
for subdiag in eq_subdiagnostics {
|
||||
diagnostic.help(subdiag);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// This function receives an unresolved `from foo import bar` import,
|
||||
/// where `foo` can be resolved to a module but that module does not
|
||||
/// have a `bar` member or submodule.
|
||||
|
||||
@@ -14,8 +14,6 @@ use ruff_text_size::{TextLen, TextRange, TextSize};
|
||||
use rustc_hash::{FxHashMap, FxHashSet};
|
||||
|
||||
use crate::Db;
|
||||
use crate::module_resolver::file_to_module;
|
||||
use crate::semantic_index::{scope::ScopeKind, semantic_index};
|
||||
use crate::types::class::{ClassLiteral, ClassType, GenericAlias};
|
||||
use crate::types::function::{FunctionType, OverloadLiteral};
|
||||
use crate::types::generics::{GenericContext, Specialization};
|
||||
@@ -27,7 +25,6 @@ use crate::types::{
|
||||
MaterializationKind, Protocol, ProtocolInstanceType, SpecialFormType, StringLiteralType,
|
||||
SubclassOfInner, Type, UnionType, WrapperDescriptorKind, visitor,
|
||||
};
|
||||
use ruff_db::parsed::parsed_module;
|
||||
|
||||
/// Settings for displaying types and signatures
|
||||
#[derive(Debug, Clone, Default)]
|
||||
@@ -342,8 +339,11 @@ impl<'db> AmbiguousClassCollector<'db> {
|
||||
match value {
|
||||
AmbiguityState::Unambiguous(existing) => {
|
||||
if *existing != class {
|
||||
let qualified_name_components = class.qualified_name_components(db);
|
||||
if existing.qualified_name_components(db) == qualified_name_components {
|
||||
let qualified_name_components =
|
||||
class.qualified_name(db).components_excluding_self();
|
||||
if existing.qualified_name(db).components_excluding_self()
|
||||
== qualified_name_components
|
||||
{
|
||||
*value = AmbiguityState::RequiresFileAndLineNumber;
|
||||
} else {
|
||||
*value = AmbiguityState::RequiresFullyQualifiedName {
|
||||
@@ -358,7 +358,8 @@ impl<'db> AmbiguousClassCollector<'db> {
|
||||
qualified_name_components,
|
||||
} => {
|
||||
if *existing != class {
|
||||
let new_components = class.qualified_name_components(db);
|
||||
let new_components =
|
||||
class.qualified_name(db).components_excluding_self();
|
||||
if *qualified_name_components == new_components {
|
||||
*value = AmbiguityState::RequiresFileAndLineNumber;
|
||||
}
|
||||
@@ -505,52 +506,6 @@ impl<'db> ClassLiteral<'db> {
|
||||
settings,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the components of the qualified name of this class, excluding this class itself.
|
||||
///
|
||||
/// For example, calling this method on a class `C` in the module `a.b` would return
|
||||
/// `["a", "b"]`. Calling this method on a class `D` inside the namespace of a method
|
||||
/// `m` inside the namespace of a class `C` in the module `a.b` would return
|
||||
/// `["a", "b", "C", "<locals of function 'm'>"]`.
|
||||
fn qualified_name_components(self, db: &'db dyn Db) -> Vec<String> {
|
||||
let body_scope = self.body_scope(db);
|
||||
let file = body_scope.file(db);
|
||||
let module_ast = parsed_module(db, file).load(db);
|
||||
let index = semantic_index(db, file);
|
||||
let file_scope_id = body_scope.file_scope_id(db);
|
||||
|
||||
let mut name_parts = vec![];
|
||||
|
||||
// Skips itself
|
||||
for (_, ancestor_scope) in index.ancestor_scopes(file_scope_id).skip(1) {
|
||||
let node = ancestor_scope.node();
|
||||
|
||||
match ancestor_scope.kind() {
|
||||
ScopeKind::Class => {
|
||||
if let Some(class_def) = node.as_class() {
|
||||
name_parts.push(class_def.node(&module_ast).name.as_str().to_string());
|
||||
}
|
||||
}
|
||||
ScopeKind::Function => {
|
||||
if let Some(function_def) = node.as_function() {
|
||||
name_parts.push(format!(
|
||||
"<locals of function '{}'>",
|
||||
function_def.node(&module_ast).name.as_str()
|
||||
));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(module) = file_to_module(db, file) {
|
||||
let module_name = module.name(db);
|
||||
name_parts.push(module_name.as_str().to_string());
|
||||
}
|
||||
|
||||
name_parts.reverse();
|
||||
name_parts
|
||||
}
|
||||
}
|
||||
|
||||
struct ClassDisplay<'db> {
|
||||
@@ -562,14 +517,14 @@ struct ClassDisplay<'db> {
|
||||
impl<'db> FmtDetailed<'db> for ClassDisplay<'db> {
|
||||
fn fmt_detailed(&self, f: &mut TypeWriter<'_, '_, 'db>) -> fmt::Result {
|
||||
let qualification_level = self.settings.qualified.get(&**self.class.name(self.db));
|
||||
|
||||
let ty = Type::ClassLiteral(self.class);
|
||||
if qualification_level.is_some() {
|
||||
for parent in self.class.qualified_name_components(self.db) {
|
||||
f.write_str(&parent)?;
|
||||
f.write_char('.')?;
|
||||
}
|
||||
write!(f.with_type(ty), "{}", self.class.qualified_name(self.db))?;
|
||||
} else {
|
||||
write!(f.with_type(ty), "{}", self.class.name(self.db))?;
|
||||
}
|
||||
f.with_type(Type::ClassLiteral(self.class))
|
||||
.write_str(self.class.name(self.db))?;
|
||||
|
||||
if qualification_level == Some(&QualificationLevel::FileAndLineNumber) {
|
||||
let file = self.class.file(self.db);
|
||||
let path = file.path(self.db);
|
||||
|
||||
@@ -451,7 +451,7 @@ impl<'db> AllMembers<'db> {
|
||||
}
|
||||
|
||||
/// A member of a type with an optional definition.
|
||||
#[derive(Clone, Debug)]
|
||||
#[derive(Clone, Debug, Eq, PartialEq, Hash)]
|
||||
pub struct MemberWithDefinition<'db> {
|
||||
pub member: Member<'db>,
|
||||
pub definition: Option<Definition<'db>>,
|
||||
|
||||
@@ -108,7 +108,7 @@ use crate::types::{
|
||||
TrackedConstraintSet, Truthiness, Type, TypeAliasType, TypeAndQualifiers, TypeContext,
|
||||
TypeQualifiers, TypeVarBoundOrConstraintsEvaluation, TypeVarDefaultEvaluation, TypeVarIdentity,
|
||||
TypeVarInstance, TypeVarKind, TypeVarVariance, TypedDictType, UnionBuilder, UnionType,
|
||||
UnionTypeInstance, binding_type, todo_type,
|
||||
UnionTypeInstance, binding_type, liskov, todo_type,
|
||||
};
|
||||
use crate::types::{ClassBase, add_inferred_python_version_hint_to_diagnostic};
|
||||
use crate::unpack::{EvaluationMode, UnpackPosition};
|
||||
@@ -548,9 +548,10 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> {
|
||||
"Inferring deferred types should not add more deferred definitions"
|
||||
);
|
||||
|
||||
// TODO: Only call this function when diagnostics are enabled.
|
||||
self.check_class_definitions();
|
||||
self.check_overloaded_functions(node);
|
||||
if self.db().should_check_file(self.file()) {
|
||||
self.check_class_definitions();
|
||||
self.check_overloaded_functions(node);
|
||||
}
|
||||
}
|
||||
|
||||
/// Iterate over all class definitions to check that the definition will not cause an exception
|
||||
@@ -949,6 +950,9 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> {
|
||||
}
|
||||
}
|
||||
|
||||
// (8) Check for Liskov violations
|
||||
liskov::check_class(&self.context, class);
|
||||
|
||||
if let Some(protocol) = class.into_protocol_class(self.db()) {
|
||||
protocol.validate_members(&self.context);
|
||||
}
|
||||
|
||||
167
crates/ty_python_semantic/src/types/liskov.rs
Normal file
167
crates/ty_python_semantic/src/types/liskov.rs
Normal file
@@ -0,0 +1,167 @@
|
||||
//! Checks relating to the [Liskov Substitution Principle].
|
||||
//!
|
||||
//! [Liskov Substitution Principle]: https://en.wikipedia.org/wiki/Liskov_substitution_principle
|
||||
|
||||
use rustc_hash::FxHashSet;
|
||||
|
||||
use crate::{
|
||||
place::Place,
|
||||
semantic_index::place_table,
|
||||
types::{
|
||||
ClassBase, ClassLiteral, ClassType, KnownClass, Type,
|
||||
class::CodeGeneratorKind,
|
||||
context::InferContext,
|
||||
diagnostic::report_invalid_method_override,
|
||||
ide_support::{MemberWithDefinition, all_declarations_and_bindings},
|
||||
},
|
||||
};
|
||||
|
||||
pub(super) fn check_class<'db>(context: &InferContext<'db, '_>, class: ClassLiteral<'db>) {
|
||||
let db = context.db();
|
||||
if class.is_known(db, KnownClass::Object) {
|
||||
return;
|
||||
}
|
||||
|
||||
let class_specialized = class.identity_specialization(db);
|
||||
let own_class_members: FxHashSet<_> =
|
||||
all_declarations_and_bindings(db, class.body_scope(db)).collect();
|
||||
|
||||
for member in own_class_members {
|
||||
check_class_declaration(context, class_specialized, &member);
|
||||
}
|
||||
}
|
||||
|
||||
fn check_class_declaration<'db>(
|
||||
context: &InferContext<'db, '_>,
|
||||
class: ClassType<'db>,
|
||||
member: &MemberWithDefinition<'db>,
|
||||
) {
|
||||
let db = context.db();
|
||||
|
||||
let MemberWithDefinition { member, definition } = member;
|
||||
|
||||
// TODO: Check Liskov on non-methods too
|
||||
let Type::FunctionLiteral(function) = member.ty else {
|
||||
return;
|
||||
};
|
||||
|
||||
let Some(definition) = definition else {
|
||||
return;
|
||||
};
|
||||
|
||||
// TODO: classmethods and staticmethods
|
||||
if function.is_classmethod(db) || function.is_staticmethod(db) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Constructor methods are not checked for Liskov compliance
|
||||
if matches!(
|
||||
&*member.name,
|
||||
"__init__" | "__new__" | "__post_init__" | "__init_subclass__"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Synthesized `__replace__` methods on dataclasses are not checked
|
||||
if &member.name == "__replace__"
|
||||
&& matches!(
|
||||
CodeGeneratorKind::from_class(db, class.class_literal(db).0, None),
|
||||
Some(CodeGeneratorKind::DataclassLike(_))
|
||||
)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
let Place::Defined(type_on_subclass_instance, _, _) =
|
||||
Type::instance(db, class).member(db, &member.name).place
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
for superclass in class.iter_mro(db).skip(1).filter_map(ClassBase::into_class) {
|
||||
let superclass_symbol_table =
|
||||
place_table(db, superclass.class_literal(db).0.body_scope(db));
|
||||
|
||||
let mut method_kind = MethodKind::NotSynthesized;
|
||||
|
||||
// If the member is not defined on the class itself, skip it
|
||||
if let Some(superclass_symbol) = superclass_symbol_table.symbol_by_name(&member.name) {
|
||||
if !(superclass_symbol.is_bound() || superclass_symbol.is_declared()) {
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
let (superclass_literal, superclass_specialization) = superclass.class_literal(db);
|
||||
if superclass_literal
|
||||
.own_synthesized_member(db, superclass_specialization, None, &member.name)
|
||||
.is_none()
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let class_kind =
|
||||
CodeGeneratorKind::from_class(db, superclass_literal, superclass_specialization);
|
||||
|
||||
method_kind = match class_kind {
|
||||
Some(CodeGeneratorKind::NamedTuple) => {
|
||||
MethodKind::Synthesized(SynthesizedMethodKind::NamedTuple)
|
||||
}
|
||||
Some(CodeGeneratorKind::DataclassLike(_)) => {
|
||||
MethodKind::Synthesized(SynthesizedMethodKind::Dataclass)
|
||||
}
|
||||
// It's invalid to define a method on a `TypedDict` (and this should be
|
||||
// reported elsewhere), but it's valid to override other things on a
|
||||
// `TypedDict`, so this case isn't relevant right now but may become
|
||||
// so when we expand Liskov checking in the future
|
||||
Some(CodeGeneratorKind::TypedDict) => {
|
||||
MethodKind::Synthesized(SynthesizedMethodKind::TypedDict)
|
||||
}
|
||||
None => MethodKind::NotSynthesized,
|
||||
};
|
||||
}
|
||||
|
||||
let Place::Defined(superclass_type, _, _) = Type::instance(db, superclass)
|
||||
.member(db, &member.name)
|
||||
.place
|
||||
else {
|
||||
// If not defined on any superclass, nothing to check
|
||||
break;
|
||||
};
|
||||
|
||||
let Some(superclass_type_as_callable) = superclass_type.try_upcast_to_callable(db) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
if type_on_subclass_instance.is_assignable_to(db, superclass_type_as_callable) {
|
||||
continue;
|
||||
}
|
||||
|
||||
report_invalid_method_override(
|
||||
context,
|
||||
&member.name,
|
||||
class,
|
||||
*definition,
|
||||
function,
|
||||
superclass,
|
||||
superclass_type,
|
||||
method_kind,
|
||||
);
|
||||
|
||||
// Only one diagnostic should be emitted per each invalid override,
|
||||
// even if it overrides multiple superclasses incorrectly!
|
||||
// It's possible `report_invalid_method_override` didn't emit a diagnostic because there's a
|
||||
// suppression comment, but that too should cause us to exit early here.
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(super) enum MethodKind {
|
||||
Synthesized(SynthesizedMethodKind),
|
||||
NotSynthesized,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(super) enum SynthesizedMethodKind {
|
||||
NamedTuple,
|
||||
Dataclass,
|
||||
TypedDict,
|
||||
}
|
||||
Reference in New Issue
Block a user