Skip to content

Commit cd2d59c

Browse files
committed
more subdiagnostics and docs
1 parent da0cdfc commit cd2d59c

7 files changed

+249
-45
lines changed

crates/ty/docs/rules.md

Lines changed: 48 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/ty_python_semantic/resources/mdtest/liskov.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -271,3 +271,14 @@ class C:
271271
class D(C):
272272
def x(self, y: int): ... # error: [invalid-method-override]
273273
```
274+
275+
## Bad override of `__eq__`
276+
277+
<!-- snapshot-diagnostics -->
278+
279+
```py
280+
class Bad:
281+
x: int
282+
def __eq__(self, other: "Bad") -> bool: # error: [invalid-method-override]
283+
return self.x == other.x
284+
```
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
---
2+
source: crates/ty_test/src/lib.rs
3+
expression: snapshot
4+
---
5+
---
6+
mdtest name: liskov.md - The Liskov Substitution Principle - Bad override of `__eq__`
7+
mdtest path: crates/ty_python_semantic/resources/mdtest/liskov.md
8+
---
9+
10+
# Python source files
11+
12+
## mdtest_snippet.py
13+
14+
```
15+
1 | class Bad:
16+
2 | x: int
17+
3 | def __eq__(self, other: "Bad") -> bool: # error: [invalid-method-override]
18+
4 | return self.x == other.x
19+
```
20+
21+
# Diagnostics
22+
23+
```
24+
error[invalid-method-override]: Invalid override of method `__eq__`
25+
--> src/mdtest_snippet.py:3:9
26+
|
27+
1 | class Bad:
28+
2 | x: int
29+
3 | def __eq__(self, other: "Bad") -> bool: # error: [invalid-method-override]
30+
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Definition is incompatible with `object.__eq__`
31+
4 | return self.x == other.x
32+
|
33+
::: stdlib/builtins.pyi:142:9
34+
|
35+
140 | def __setattr__(self, name: str, value: Any, /) -> None: ...
36+
141 | def __delattr__(self, name: str, /) -> None: ...
37+
142 | def __eq__(self, value: object, /) -> bool: ...
38+
| -------------------------------------- `object.__eq__` defined here
39+
143 | def __ne__(self, value: object, /) -> bool: ...
40+
144 | def __str__(self) -> str: ... # noqa: Y029
41+
|
42+
info: This violates the Liskov Substitution Principle
43+
help: It is recommended for `__eq__` to work with arbitrary objects, for example:
44+
help
45+
help: def __eq__(self, other: object) -> bool:
46+
help: if not isinstance(other, Bad):
47+
help: return False
48+
help: return <logic to compare two `Bad` instances>
49+
help
50+
info: rule `invalid-method-override` is enabled by default
51+
52+
```

crates/ty_python_semantic/resources/mdtest/snapshots/liskov.md_-_The_Liskov_Substitut…_-_Edge_case___function_…_(c8756a54d1cb8499).snap

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,11 @@ error[invalid-method-override]: Invalid override of method `x`
6666
13 | def x(self, y: int): ... # error: [invalid-method-override]
6767
| ^^^^^^^^^^^^^^^ Definition is incompatible with `C.x`
6868
|
69+
::: src/foo.pyi:1:5
70+
|
71+
1 | def x(self, y: str): ...
72+
| --------------- Signature of `C.x`
73+
|
6974
info: This violates the Liskov Substitution Principle
7075
info: rule `invalid-method-override` is enabled by default
7176

crates/ty_python_semantic/resources/mdtest/snapshots/tuples.md_-_Comparison___Tuples_-_Equality_with_elemen…_(39b614d4707c0661).snap

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,13 @@ error[invalid-method-override]: Invalid override of method `__eq__`
4646
144 | def __str__(self) -> str: ... # noqa: Y029
4747
|
4848
info: This violates the Liskov Substitution Principle
49+
help: It is recommended for `__eq__` to work with arbitrary objects, for example:
50+
help
51+
help: def __eq__(self, other: object) -> bool:
52+
help: if not isinstance(other, A):
53+
help: return False
54+
help: return <logic to compare two `A` instances>
55+
help
4956
info: rule `invalid-method-override` is enabled by default
5057
5158
```

crates/ty_python_semantic/src/types/diagnostic.rs

Lines changed: 124 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1938,7 +1938,14 @@ declare_lint! {
19381938

19391939
declare_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

Comments
 (0)