red_knot_python_semantic: improve diagnostics for unsupported boolean conversions

This mostly only improves things for incorrect arguments and for an
incorrect return type. It doesn't do much to improve the case where
`__bool__` isn't callable and leaves the union/other cases untouched
completely.

I picked this one because, at first glance, this _looked_ like a lower
hanging fruit. The conceptual improvement here is pretty
straight-forward: add annotations for relevant data. But it took me a
bit to figure out how to connect all of the pieces.
This commit is contained in:
Andrew Gallant 2025-04-23 12:28:49 -04:00 committed by Andrew Gallant
parent eb1d2518c1
commit 0f47810768
19 changed files with 107 additions and 35 deletions

View File

@ -42,6 +42,6 @@ def _(flag: bool):
class NotBoolable:
__bool__: int = 3
# error: [unsupported-bool-conversion] "Boolean conversion is unsupported for type `NotBoolable`; its `__bool__` method isn't callable"
# error: [unsupported-bool-conversion] "Boolean conversion is unsupported for type `NotBoolable`"
3 if NotBoolable() else 4
```

View File

@ -154,10 +154,10 @@ def _(flag: bool):
class NotBoolable:
__bool__: int = 3
# error: [unsupported-bool-conversion] "Boolean conversion is unsupported for type `NotBoolable`; its `__bool__` method isn't callable"
# error: [unsupported-bool-conversion] "Boolean conversion is unsupported for type `NotBoolable`"
if NotBoolable():
...
# error: [unsupported-bool-conversion] "Boolean conversion is unsupported for type `NotBoolable`; its `__bool__` method isn't callable"
# error: [unsupported-bool-conversion] "Boolean conversion is unsupported for type `NotBoolable`"
elif NotBoolable():
...
```

View File

@ -292,7 +292,7 @@ class NotBoolable:
def _(target: int, flag: NotBoolable):
y = 1
match target:
# error: [unsupported-bool-conversion] "Boolean conversion is unsupported for type `NotBoolable`; its `__bool__` method isn't callable"
# error: [unsupported-bool-conversion] "Boolean conversion is unsupported for type `NotBoolable`"
case 1 if flag:
y = 2
case 2:

View File

@ -4,6 +4,6 @@
class NotBoolable:
__bool__: int = 3
# error: [unsupported-bool-conversion] "Boolean conversion is unsupported for type `NotBoolable`; its `__bool__` method isn't callable"
# error: [unsupported-bool-conversion] "Boolean conversion is unsupported for type `NotBoolable`"
assert NotBoolable()
```

View File

@ -123,7 +123,7 @@ if NotBoolable():
class NotBoolable:
__bool__: None = None
# error: [unsupported-bool-conversion] "Boolean conversion is unsupported for type `NotBoolable`; its `__bool__` method isn't callable"
# error: [unsupported-bool-conversion] "Boolean conversion is unsupported for type `NotBoolable`"
if NotBoolable():
...
```
@ -135,7 +135,7 @@ def test(cond: bool):
class NotBoolable:
__bool__: int | None = None if cond else 3
# error: [unsupported-bool-conversion] "Boolean conversion is unsupported for type `NotBoolable`; its `__bool__` method isn't callable"
# error: [unsupported-bool-conversion] "Boolean conversion is unsupported for type `NotBoolable`"
if NotBoolable():
...
```
@ -149,7 +149,7 @@ def test(cond: bool):
a = 10 if cond else NotBoolable()
# error: [unsupported-bool-conversion] "Boolean conversion is unsupported for type `Literal[10] | NotBoolable`; its `__bool__` method isn't callable"
# error: [unsupported-bool-conversion] "Boolean conversion is unsupported for type `Literal[10] | NotBoolable`"
if a:
...
```

View File

@ -123,7 +123,7 @@ def _(flag: bool, flag2: bool):
class NotBoolable:
__bool__: int = 3
# error: [unsupported-bool-conversion] "Boolean conversion is unsupported for type `NotBoolable`; its `__bool__` method isn't callable"
# error: [unsupported-bool-conversion] "Boolean conversion is unsupported for type `NotBoolable`"
while NotBoolable():
...
```

View File

@ -270,7 +270,7 @@ def _(
if af:
reveal_type(af) # revealed: type[AmbiguousClass] & ~AlwaysFalsy
# error: [unsupported-bool-conversion] "Boolean conversion is unsupported for type `MetaDeferred`; the return type of its bool method (`MetaAmbiguous`) isn't assignable to `bool"
# error: [unsupported-bool-conversion] "Boolean conversion is unsupported for type `MetaDeferred`"
if d:
# TODO: Should be `Unknown`
reveal_type(d) # revealed: type[DeferredClass] & ~AlwaysFalsy

View File

@ -24,12 +24,13 @@ mdtest path: crates/red_knot_python_semantic/resources/mdtest/binary/instances.m
# Diagnostics
```
error: lint:unsupported-bool-conversion: Boolean conversion is unsupported for type `NotBoolable`; its `__bool__` method isn't callable
error: lint:unsupported-bool-conversion: Boolean conversion is unsupported for type `NotBoolable`
--> /src/mdtest_snippet.py:7:8
|
6 | # error: [unsupported-bool-conversion]
7 | 10 and a and True
| ^
|
info: `__bool__` on `NotBoolable` must be callable
```

View File

@ -28,7 +28,7 @@ mdtest path: crates/red_knot_python_semantic/resources/mdtest/comparison/instanc
# Diagnostics
```
error: lint:unsupported-bool-conversion: Boolean conversion is unsupported for type `NotBoolable`; its `__bool__` method isn't callable
error: lint:unsupported-bool-conversion: Boolean conversion is unsupported for type `NotBoolable`
--> /src/mdtest_snippet.py:9:1
|
8 | # error: [unsupported-bool-conversion]
@ -37,11 +37,12 @@ error: lint:unsupported-bool-conversion: Boolean conversion is unsupported for t
10 | # error: [unsupported-bool-conversion]
11 | 10 not in WithContains()
|
info: `__bool__` on `NotBoolable` must be callable
```
```
error: lint:unsupported-bool-conversion: Boolean conversion is unsupported for type `NotBoolable`; its `__bool__` method isn't callable
error: lint:unsupported-bool-conversion: Boolean conversion is unsupported for type `NotBoolable`
--> /src/mdtest_snippet.py:11:1
|
9 | 10 in WithContains()
@ -49,5 +50,6 @@ error: lint:unsupported-bool-conversion: Boolean conversion is unsupported for t
11 | 10 not in WithContains()
| ^^^^^^^^^^^^^^^^^^^^^^^^
|
info: `__bool__` on `NotBoolable` must be callable
```

View File

@ -22,12 +22,13 @@ mdtest path: crates/red_knot_python_semantic/resources/mdtest/unary/not.md
# Diagnostics
```
error: lint:unsupported-bool-conversion: Boolean conversion is unsupported for type `NotBoolable`; its `__bool__` method isn't callable
error: lint:unsupported-bool-conversion: Boolean conversion is unsupported for type `NotBoolable`
--> /src/mdtest_snippet.py:5:1
|
4 | # error: [unsupported-bool-conversion]
5 | not NotBoolable()
| ^^^^^^^^^^^^^^^^^
|
info: `__bool__` on `NotBoolable` must be callable
```

View File

@ -33,7 +33,7 @@ mdtest path: crates/red_knot_python_semantic/resources/mdtest/comparison/instanc
# Diagnostics
```
error: lint:unsupported-bool-conversion: Boolean conversion is unsupported for type `NotBoolable`; its `__bool__` method isn't callable
error: lint:unsupported-bool-conversion: Boolean conversion is unsupported for type `NotBoolable`
--> /src/mdtest_snippet.py:12:1
|
11 | # error: [unsupported-bool-conversion]
@ -42,11 +42,12 @@ error: lint:unsupported-bool-conversion: Boolean conversion is unsupported for t
13 | # error: [unsupported-bool-conversion]
14 | 10 < Comparable() < Comparable()
|
info: `__bool__` on `NotBoolable` must be callable
```
```
error: lint:unsupported-bool-conversion: Boolean conversion is unsupported for type `NotBoolable`; its `__bool__` method isn't callable
error: lint:unsupported-bool-conversion: Boolean conversion is unsupported for type `NotBoolable`
--> /src/mdtest_snippet.py:14:1
|
12 | 10 < Comparable() < 20
@ -56,5 +57,6 @@ error: lint:unsupported-bool-conversion: Boolean conversion is unsupported for t
15 |
16 | Comparable() < Comparable() # fine
|
info: `__bool__` on `NotBoolable` must be callable
```

View File

@ -34,7 +34,7 @@ mdtest path: crates/red_knot_python_semantic/resources/mdtest/comparison/tuples.
# Diagnostics
```
error: lint:unsupported-bool-conversion: Boolean conversion is unsupported for type `NotBoolable | Literal[False]`; its `__bool__` method isn't callable
error: lint:unsupported-bool-conversion: Boolean conversion is unsupported for type `NotBoolable | Literal[False]`
--> /src/mdtest_snippet.py:15:1
|
14 | # error: [unsupported-bool-conversion]
@ -43,5 +43,6 @@ error: lint:unsupported-bool-conversion: Boolean conversion is unsupported for t
16 |
17 | a < b # fine
|
info: `__bool__` on `NotBoolable | Literal[False]` must be callable
```

View File

@ -26,12 +26,13 @@ mdtest path: crates/red_knot_python_semantic/resources/mdtest/comparison/tuples.
# Diagnostics
```
error: lint:unsupported-bool-conversion: Boolean conversion is unsupported for type `NotBoolable`; its `__bool__` method isn't callable
error: lint:unsupported-bool-conversion: Boolean conversion is unsupported for type `NotBoolable`
--> /src/mdtest_snippet.py:9:1
|
8 | # error: [unsupported-bool-conversion]
9 | (A(),) == (A(),)
| ^^^^^^^^^^^^^^^^
|
info: `__bool__` on `NotBoolable` must be callable
```

View File

@ -24,12 +24,13 @@ mdtest path: crates/red_knot_python_semantic/resources/mdtest/diagnostics/unsupp
# Diagnostics
```
error: lint:unsupported-bool-conversion: Boolean conversion is unsupported for type `NotBoolable`; its `__bool__` method isn't callable
error: lint:unsupported-bool-conversion: Boolean conversion is unsupported for type `NotBoolable`
--> /src/mdtest_snippet.py:7:8
|
6 | # error: [unsupported-bool-conversion]
7 | 10 and a and True
| ^
|
info: `__bool__` must be callable
```

View File

@ -25,12 +25,22 @@ mdtest path: crates/red_knot_python_semantic/resources/mdtest/diagnostics/unsupp
# Diagnostics
```
error: lint:unsupported-bool-conversion: Boolean conversion is unsupported for type `NotBoolable`; the return type of its bool method (`str`) isn't assignable to `bool
error: lint:unsupported-bool-conversion: Boolean conversion is unsupported for type `NotBoolable`
--> /src/mdtest_snippet.py:8:8
|
7 | # error: [unsupported-bool-conversion]
8 | 10 and a and True
| ^
|
info: `str` is not assignable to `bool`
--> /src/mdtest_snippet.py:2:9
|
1 | class NotBoolable:
2 | def __bool__(self) -> str:
| -------- ^^^ Incorrect return type
| |
| Method defined here
3 | return "wat"
|
```

View File

@ -25,12 +25,22 @@ mdtest path: crates/red_knot_python_semantic/resources/mdtest/diagnostics/unsupp
# Diagnostics
```
error: lint:unsupported-bool-conversion: Boolean conversion is unsupported for type `NotBoolable`; it incorrectly implements `__bool__`
error: lint:unsupported-bool-conversion: Boolean conversion is unsupported for type `NotBoolable`
--> /src/mdtest_snippet.py:8:8
|
7 | # error: [unsupported-bool-conversion]
8 | 10 and a and True
| ^
|
info: `__bool__` methods must only have a `self` parameter
--> /src/mdtest_snippet.py:2:9
|
1 | class NotBoolable:
2 | def __bool__(self, foo):
| --------^^^^^^^^^^^ Incorrect parameters
| |
| Method defined here
3 | return False
|
```

View File

@ -235,7 +235,7 @@ class InvalidBoolDunder:
def __bool__(self) -> int:
return 1
# error: [unsupported-bool-conversion] "Boolean conversion is unsupported for type `InvalidBoolDunder`; the return type of its bool method (`int`) isn't assignable to `bool"
# error: [unsupported-bool-conversion] "Boolean conversion is unsupported for type `InvalidBoolDunder`"
static_assert(InvalidBoolDunder())
```

View File

@ -187,7 +187,7 @@ class MethodBoolInvalid:
def __bool__(self) -> int:
return 0
# error: [unsupported-bool-conversion] "Boolean conversion is unsupported for type `MethodBoolInvalid`; the return type of its bool method (`int`) isn't assignable to `bool"
# error: [unsupported-bool-conversion] "Boolean conversion is unsupported for type `MethodBoolInvalid`"
# revealed: bool
reveal_type(not MethodBoolInvalid())

View File

@ -10,7 +10,9 @@ use diagnostic::{
CALL_POSSIBLY_UNBOUND_METHOD, INVALID_CONTEXT_MANAGER, INVALID_SUPER_ARGUMENT, NOT_ITERABLE,
UNAVAILABLE_IMPLICIT_SUPER_ARGUMENTS,
};
use ruff_db::diagnostic::create_semantic_syntax_diagnostic;
use ruff_db::diagnostic::{
create_semantic_syntax_diagnostic, Annotation, Severity, Span, SubDiagnostic,
};
use ruff_db::files::{File, FileRange};
use ruff_python_ast::name::Name;
use ruff_python_ast::{self as ast, AnyNodeRef};
@ -5763,30 +5765,71 @@ impl<'db> BoolError<'db> {
Self::IncorrectArguments {
not_boolable_type, ..
} => {
builder.into_diagnostic(format_args!(
"Boolean conversion is unsupported for type `{}`; \
it incorrectly implements `__bool__`",
let mut diag = builder.into_diagnostic(format_args!(
"Boolean conversion is unsupported for type `{}`",
not_boolable_type.display(context.db())
));
let mut sub = SubDiagnostic::new(
Severity::Info,
"`__bool__` methods must only have a `self` parameter",
);
if let Some((func_span, parameter_span)) = not_boolable_type
.member(context.db(), "__bool__")
.into_lookup_result()
.ok()
.and_then(|quals| quals.inner_type().parameter_span(context.db(), None))
{
sub.annotate(
Annotation::primary(parameter_span).message("Incorrect parameters"),
);
sub.annotate(Annotation::secondary(func_span).message("Method defined here"));
}
diag.sub(sub);
}
Self::IncorrectReturnType {
not_boolable_type,
return_type,
} => {
builder.into_diagnostic(format_args!(
"Boolean conversion is unsupported for type `{not_boolable}`; \
the return type of its bool method (`{return_type}`) \
isn't assignable to `bool",
let mut diag = builder.into_diagnostic(format_args!(
"Boolean conversion is unsupported for type `{not_boolable}`",
not_boolable = not_boolable_type.display(context.db()),
return_type = return_type.display(context.db())
));
let mut sub = SubDiagnostic::new(
Severity::Info,
format_args!(
"`{return_type}` is not assignable to `bool`",
return_type = return_type.display(context.db()),
),
);
if let Some((func_span, return_type_span)) = not_boolable_type
.member(context.db(), "__bool__")
.into_lookup_result()
.ok()
.and_then(|quals| quals.inner_type().return_type_span(context.db()))
{
sub.annotate(
Annotation::primary(return_type_span).message("Incorrect return type"),
);
sub.annotate(Annotation::secondary(func_span).message("Method defined here"));
}
diag.sub(sub);
}
Self::NotCallable { not_boolable_type } => {
builder.into_diagnostic(format_args!(
"Boolean conversion is unsupported for type `{}`; \
its `__bool__` method isn't callable",
let mut diag = builder.into_diagnostic(format_args!(
"Boolean conversion is unsupported for type `{}`",
not_boolable_type.display(context.db())
));
let sub = SubDiagnostic::new(
Severity::Info,
format_args!(
"`__bool__` on `{}` must be callable",
not_boolable_type.display(context.db())
),
);
// TODO: It would be nice to create an annotation here for
// where `__bool__` is defined. At time of writing, I couldn't
// figure out a straight-forward way of doing this. ---AG
diag.sub(sub);
}
Self::Union { union, .. } => {
let first_error = union