@@ -1938,7 +1938,14 @@ declare_lint! {
19381938
19391939declare_lint ! {
19401940 /// ## What it does
1941- /// Detects method overrides that violate the [Liskov Substitution Principle].
1941+ /// Detects method overrides that violate the [Liskov Substitution Principle] ("LSP").
1942+ ///
1943+ /// The LSP states that any subtype should be substitutable for its supertype.
1944+ /// Applied to Python, this means:
1945+ /// 1. All argument combinations a superclass method accepts
1946+ /// must also be accepted by an overriding subclass method.
1947+ /// 2. The return type of an overriding subclass method must be a subtype
1948+ /// of the return type of the superclass method.
19421949 ///
19431950 /// ## Why is this bad?
19441951 /// Violating the Liskov Substitution Principle will lead to many of ty's assumptions and
@@ -1948,25 +1955,60 @@ declare_lint! {
19481955 /// ## Example
19491956 /// ```python
19501957 /// class Super:
1951- /// def method(self) -> int:
1958+ /// def method(self, x ) -> int:
19521959 /// return 42
19531960 ///
19541961 /// class Sub(Super):
19551962 /// # Liskov violation: `str` is not a subtype of `int`,
19561963 /// # but the supertype method promises to return an `int`.
1957- /// def method(self) -> str: # error: [invalid-override]
1964+ /// def method(self, x ) -> str: # error: [invalid-override]
19581965 /// return "foo"
19591966 ///
19601967 /// def accepts_super(s: Super) -> int:
1961- /// return s.method()
1968+ /// return s.method(x=42 )
19621969 ///
19631970 /// accepts_super(Sub()) # The result of this call is a string, but ty will infer
19641971 /// # it to be an `int` due to the violation of the Liskov Substitution Principle.
1972+ ///
1973+ /// class Sub2(Super):
1974+ /// # Liskov violation: the superclass method can be called with a `x=`
1975+ /// # keyword argument, but the subclass method does not accept it.
1976+ /// def method(self, y) -> int: # error: [invalid-override]
1977+ /// return 42
1978+ ///
1979+ /// accepts_super(Sub2()) # TypeError at runtime: method() got an unexpected keyword argument 'x'
1980+ /// # ty cannot catch this error due to the violation of the Liskov Substitution Principle.
1981+ /// ```
1982+ ///
1983+ /// ## Common issues
1984+ ///
1985+ /// ### Why does ty complain about my `__eq__` method?
1986+ ///
1987+ /// `__eq__` and `__ne__` methods in Python are generally expected to accept arbitrary
1988+ /// objects as their second argument, for example:
1989+ ///
1990+ /// ```python
1991+ /// class A:
1992+ /// x: int
1993+ ///
1994+ /// def __eq__(self, other: object) -> bool:
1995+ /// # gracefully handle an object of an unexpected type
1996+ /// # without raising an exception
1997+ /// if not isinstance(other, A):
1998+ /// return False
1999+ /// return self.x == other.x
19652000 /// ```
19662001 ///
2002+ /// If `A.__eq__` here were annotated as only accepting `A` instances for its second argument,
2003+ /// it would imply that you wouldn't be able to use `==` between instances of `A` and
2004+ /// instances of unrelated classes without an exception possibly being raised. While some
2005+ /// classes in Python do indeed behave this way, the strongly held convention is that it should
2006+ /// be avoided wherever possible. As part of this check, therefore, ty enforces that `__eq__`
2007+ /// and `__ne__` methods accept `object` as their second argument.
2008+ ///
19672009 /// [Liskov Substitution Principle]: https://en.wikipedia.org/wiki/Liskov_substitution_principle
19682010 pub ( crate ) static INVALID_METHOD_OVERRIDE = {
1969- summary: "detects missing required keys in `TypedDict` constructors " ,
2011+ summary: "detects method definitions that violate the Liskov Substitution Principle " ,
19702012 status: LintStatus :: stable( "0.0.1-alpha.20" ) ,
19712013 default_level: Level :: Error ,
19722014 }
@@ -3360,23 +3402,25 @@ pub(super) fn report_invalid_method_override<'db>(
33603402 } ;
33613403
33623404 let db = context. db ( ) ;
3405+ let member = & member. name ;
3406+ let class_name = class. name ( db) ;
33633407
33643408 let mut diagnostic =
3365- builder. into_diagnostic ( format_args ! ( "Invalid override of method `{}`" , member . name ) ) ;
3409+ builder. into_diagnostic ( format_args ! ( "Invalid override of method `{member }`" ) ) ;
33663410
33673411 let supercls_name = supercls. name ( db) ;
33683412
3369- let overridden_method = if class . name ( db ) == supercls_name {
3413+ let overridden_method = if class_name == supercls_name {
33703414 format ! (
33713415 "{class}.{member}" ,
33723416 class = supercls. class_literal( db) . 0 . qualified_name( db) ,
3373- member = & member. name
3417+ member = member
33743418 )
33753419 } else {
33763420 format ! (
33773421 "{class}.{member}" ,
33783422 class = supercls. name( db) ,
3379- member = & member. name
3423+ member = member
33803424 )
33813425 } ;
33823426
@@ -3386,41 +3430,82 @@ pub(super) fn report_invalid_method_override<'db>(
33863430
33873431 diagnostic. info ( "This violates the Liskov Substitution Principle" ) ;
33883432
3389- let secondary_span = type_on_supercls
3433+ let supercls_scope = supercls. class_literal ( db) . 0 . body_scope ( db) ;
3434+
3435+ let Some ( symbol_on_supercls) = place_table ( db, supercls_scope) . symbol_id ( member) else {
3436+ return ;
3437+ } ;
3438+
3439+ let Some ( binding) = use_def_map ( db, supercls_scope)
3440+ . end_of_scope_bindings ( ScopedPlaceId :: Symbol ( symbol_on_supercls) )
3441+ . next ( )
3442+ else {
3443+ return ;
3444+ } ;
3445+
3446+ let Some ( definition) = binding. binding . definition ( ) else {
3447+ return ;
3448+ } ;
3449+
3450+ let definition_span =
3451+ Span :: from ( definition. full_range ( db, & parsed_module ( db, supercls_scope. file ( db) ) . load ( db) ) ) ;
3452+
3453+ let original_function_span = type_on_supercls
33903454 . as_bound_method ( )
3391- . and_then ( |method_on_supercls| {
3392- let supercls_scope = supercls. class_literal ( db) . 0 . body_scope ( db) ;
3393-
3394- if method_on_supercls. function ( db) . definition ( db) . scope ( db) == supercls_scope {
3395- method_on_supercls
3396- . function ( db)
3397- . literal ( db)
3398- . last_definition ( db)
3399- . spans ( db)
3400- . map ( |spans| spans. signature )
3401- } else {
3402- place_table ( db, supercls_scope)
3403- . symbol_id ( & member. name )
3404- . and_then ( |symbol| {
3405- use_def_map ( db, supercls_scope)
3406- . end_of_scope_bindings ( ScopedPlaceId :: Symbol ( symbol) )
3407- . next ( )
3408- } )
3409- . and_then ( |binding| binding. binding . definition ( ) )
3410- . map ( |definition| {
3411- definition
3412- . full_range ( db, & parsed_module ( db, supercls_scope. file ( db) ) . load ( db) )
3413- } )
3414- . map ( Span :: from)
3415- }
3416- } ) ;
3455+ . and_then ( |method| {
3456+ method
3457+ . function ( db)
3458+ . literal ( db)
3459+ . last_definition ( db)
3460+ . spans ( db)
3461+ } )
3462+ . map ( |spans| spans. signature ) ;
3463+
3464+ let definition_kind = definition. kind ( db) ;
3465+
3466+ let secondary_span = if definition_kind. is_function_def ( )
3467+ && let Some ( function_span) = original_function_span. clone ( )
3468+ {
3469+ function_span
3470+ } else {
3471+ definition_span
3472+ } ;
34173473
3418- if let Some ( secondary_span) = secondary_span {
3474+ diagnostic. annotate (
3475+ Annotation :: secondary ( secondary_span. clone ( ) )
3476+ . message ( format_args ! ( "`{overridden_method}` defined here" ) ) ,
3477+ ) ;
3478+
3479+ if !definition_kind. is_function_def ( )
3480+ && let Some ( function_span) = original_function_span
3481+ && function_span != secondary_span
3482+ {
34193483 diagnostic. annotate (
3420- Annotation :: secondary ( secondary_span )
3421- . message ( format_args ! ( "`{overridden_method}` defined here " ) ) ,
3484+ Annotation :: secondary ( function_span )
3485+ . message ( format_args ! ( "Signature of `{overridden_method}`" ) ) ,
34223486 ) ;
34233487 }
3488+
3489+ if !( supercls. is_object ( db) && matches ! ( & * * member, "__eq__" | "__ne__" ) ) {
3490+ return ;
3491+ }
3492+
3493+ // Inspired by mypy's subdiagnostic at <https://github.com/python/mypy/blob/1b6ebb17b7fe64488a7b3c3b4b0187bb14fe331b/mypy/messages.py#L1307-L1318>
3494+ let eq_subdiagnostics = [
3495+ format_args ! (
3496+ "It is recommended for `{member}` to work with arbitrary objects, for example:" ,
3497+ ) ,
3498+ format_args ! ( "" ) ,
3499+ format_args ! ( " def {member}(self, other: object) -> bool:" , ) ,
3500+ format_args ! ( " if not isinstance(other, {class_name}):" , ) ,
3501+ format_args ! ( " return False" ) ,
3502+ format_args ! ( " return <logic to compare two `{class_name}` instances>" ) ,
3503+ format_args ! ( "" ) ,
3504+ ] ;
3505+
3506+ for subdiag in eq_subdiagnostics {
3507+ diagnostic. help ( subdiag) ;
3508+ }
34243509}
34253510
34263511/// This function receives an unresolved `from foo import bar` import,
0 commit comments