[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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user