[red-knot] Diagnostics for incorrect bool usages (#16238)

This commit is contained in:
Micha Reiser 2025-02-21 18:26:05 +00:00 committed by GitHub
parent 3aa7ba31b1
commit 5fab97f1ef
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 1267 additions and 260 deletions

View file

@ -351,6 +351,20 @@ class Y(Foo): ...
reveal_type(X() + Y()) # revealed: int
```
## Operations involving types with invalid `__bool__` methods
<!-- snapshot-diagnostics -->
```py
class NotBoolable:
__bool__ = 3
a = NotBoolable()
# error: [unsupported-bool-conversion]
10 and a and True
```
## Unsupported
### Dunder as instance attribute

View file

@ -0,0 +1,14 @@
# Calling builtins
## `bool` with incorrect arguments
```py
class NotBool:
__bool__ = None
# TODO: We should emit an `invalid-argument` error here for `2` because `bool` only takes one argument.
bool(1, 2)
# TODO: We should emit an `unsupported-bool-conversion` error here because the argument doesn't implement `__bool__` correctly.
bool(NotBool())
```

View file

@ -160,3 +160,45 @@ reveal_type(42 in A()) # revealed: bool
# error: [unsupported-operator] "Operator `in` is not supported for types `str` and `A`, in comparing `Literal["hello"]` with `A`"
reveal_type("hello" in A()) # revealed: bool
```
## Return type that doesn't implement `__bool__` correctly
`in` and `not in` operations will fail at runtime if the object on the right-hand side of the
operation has a `__contains__` method that returns a type which is not convertible to `bool`. This
is because of the way these operations are handled by the Python interpreter at runtime. If we
assume that `y` is an object that has a `__contains__` method, the Python expression `x in y`
desugars to a `contains(y, x)` call, where `contains` looks something like this:
```ignore
def contains(y, x):
return bool(type(y).__contains__(y, x))
```
where the `bool()` conversion itself implicitly calls `__bool__` under the hood.
TODO: Ideally the message would explain to the user what's wrong. E.g,
```ignore
error: [operator] cannot use `in` operator on object of type `WithContains`
note: This is because the `in` operator implicitly calls `WithContains.__contains__`, but `WithContains.__contains__` is invalidly defined
note: `WithContains.__contains__` is invalidly defined because it returns an instance of `NotBoolable`, which cannot be evaluated in a boolean context
note: `NotBoolable` cannot be evaluated in a boolean context because its `__bool__` attribute is not callable
```
It may also be more appropriate to use `unsupported-operator` as the error code.
<!-- snapshot-diagnostics -->
```py
class NotBoolable:
__bool__ = 3
class WithContains:
def __contains__(self, item) -> NotBoolable:
return NotBoolable()
# error: [unsupported-bool-conversion]
10 in WithContains()
# error: [unsupported-bool-conversion]
10 not in WithContains()
```

View file

@ -345,3 +345,29 @@ def f(x: bool, y: int):
reveal_type(4.2 < x) # revealed: bool
reveal_type(x < 4.2) # revealed: bool
```
## Chained comparisons with objects that don't implement `__bool__` correctly
<!-- snapshot-diagnostics -->
Python implicitly calls `bool` on the comparison result of preceding elements (but not for the last
element) of a chained comparison.
```py
class NotBoolable:
__bool__ = 3
class Comparable:
def __lt__(self, item) -> NotBoolable:
return NotBoolable()
def __gt__(self, item) -> NotBoolable:
return NotBoolable()
# error: [unsupported-bool-conversion]
10 < Comparable() < 20
# error: [unsupported-bool-conversion]
10 < Comparable() < Comparable()
Comparable() < Comparable() # fine
```

View file

@ -334,3 +334,61 @@ reveal_type(a is not c) # revealed: Literal[True]
For tuples like `tuple[int, ...]`, `tuple[Any, ...]`
// TODO
## Chained comparisons with elements that incorrectly implement `__bool__`
<!-- snapshot-diagnostics -->
For an operation `A() < A()` to succeed at runtime, the `A.__lt__` method does not necessarily need
to return an object that is convertible to a `bool`. However, the return type _does_ need to be
convertible to a `bool` for the operation `A() < A() < A()` (a _chained_ comparison) to succeed.
This is because `A() < A() < A()` desugars to something like this, which involves several implicit
conversions to `bool`:
```ignore
def compute_chained_comparison():
a1 = A()
a2 = A()
first_comparison = a1 < a2
return first_comparison and (a2 < A())
```
```py
class NotBoolable:
__bool__ = 5
class Comparable:
def __lt__(self, other) -> NotBoolable:
return NotBoolable()
def __gt__(self, other) -> NotBoolable:
return NotBoolable()
a = (1, Comparable())
b = (1, Comparable())
# error: [unsupported-bool-conversion]
a < b < b
a < b # fine
```
## Equality with elements that incorrectly implement `__bool__`
<!-- snapshot-diagnostics -->
Python does not generally attempt to coerce the result of `==` and `!=` operations between two
arbitrary objects to a `bool`, but a comparison of tuples will fail if the result of comparing any
pair of elements at equivalent positions cannot be converted to a `bool`:
```py
class A:
def __eq__(self, other) -> NotBoolable:
return NotBoolable()
class NotBoolable:
__bool__ = None
# error: [unsupported-bool-conversion]
(A(),) == (A(),)
```

View file

@ -35,3 +35,13 @@ def _(flag: bool):
x = 1 if flag else None
reveal_type(x) # revealed: Literal[1] | None
```
## Condition with object that implements `__bool__` incorrectly
```py
class NotBoolable:
__bool__ = 3
# error: [unsupported-bool-conversion] "Boolean conversion is unsupported for type `NotBoolable`; its `__bool__` method isn't callable"
3 if NotBoolable() else 4
```

View file

@ -147,3 +147,17 @@ def _(flag: bool):
reveal_type(y) # revealed: Literal[0, 1]
```
## Condition with object that implements `__bool__` incorrectly
```py
class NotBoolable:
__bool__ = 3
# error: [unsupported-bool-conversion] "Boolean conversion is unsupported for type `NotBoolable`; its `__bool__` method isn't callable"
if NotBoolable():
...
# error: [unsupported-bool-conversion] "Boolean conversion is unsupported for type `NotBoolable`; its `__bool__` method isn't callable"
elif NotBoolable():
...
```

View file

@ -43,3 +43,21 @@ def _(target: int):
reveal_type(y) # revealed: Literal[2, 3, 4]
```
## Guard with object that implements `__bool__` incorrectly
```py
class NotBoolable:
__bool__ = 3
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"
case 1 if flag:
y = 2
case 2:
y = 3
reveal_type(y) # revealed: Literal[1, 2, 3]
```

View file

@ -0,0 +1,9 @@
## Condition with object that implements `__bool__` incorrectly
```py
class NotBoolable:
__bool__ = 3
# error: [unsupported-bool-conversion] "Boolean conversion is unsupported for type `NotBoolable`; its `__bool__` method isn't callable"
assert NotBoolable()
```

View file

@ -101,3 +101,55 @@ reveal_type(bool([])) # revealed: bool
reveal_type(bool({})) # revealed: bool
reveal_type(bool(set())) # revealed: bool
```
## `__bool__` returning `NoReturn`
```py
from typing import NoReturn
class NotBoolable:
def __bool__(self) -> NoReturn:
raise NotImplementedError("This object can't be converted to a boolean")
# TODO: This should emit an error that `NotBoolable` can't be converted to a bool but it currently doesn't
# because `Never` is assignable to `bool`. This probably requires dead code analysis to fix.
if NotBoolable():
...
```
## Not callable `__bool__`
```py
class NotBoolable:
__bool__ = None
# error: [unsupported-bool-conversion] "Boolean conversion is unsupported for type `NotBoolable`; its `__bool__` method isn't callable"
if NotBoolable():
...
```
## Not-boolable union
```py
def test(cond: bool):
class NotBoolable:
__bool__ = None if cond else 3
# error: [unsupported-bool-conversion] "Boolean conversion is unsupported for type `NotBoolable`; it incorrectly implements `__bool__`"
if NotBoolable():
...
```
## Union with some variants implementing `__bool__` incorrectly
```py
def test(cond: bool):
class NotBoolable:
__bool__: int
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"
if a:
...
```

View file

@ -116,3 +116,14 @@ def _(flag: bool, flag2: bool):
# error: [possibly-unresolved-reference]
y
```
## Condition with object that implements `__bool__` incorrectly
```py
class NotBoolable:
__bool__ = 3
# error: [unsupported-bool-conversion] "Boolean conversion is unsupported for type `NotBoolable`; its `__bool__` method isn't callable"
while NotBoolable():
...
```

View file

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

View file

@ -0,0 +1,35 @@
---
source: crates/red_knot_test/src/lib.rs
expression: snapshot
---
---
mdtest name: instances.md - Binary operations on instances - Operations involving types with invalid `__bool__` methods
mdtest path: crates/red_knot_python_semantic/resources/mdtest/binary/instances.md
---
# Python source files
## mdtest_snippet.py
```
1 | class NotBoolable:
2 | __bool__ = 3
3 |
4 | a = NotBoolable()
5 |
6 | # error: [unsupported-bool-conversion]
7 | 10 and a and True
```
# Diagnostics
```
error: lint:unsupported-bool-conversion
--> /src/mdtest_snippet.py:7:8
|
6 | # error: [unsupported-bool-conversion]
7 | 10 and a and True
| ^ Boolean conversion is unsupported for type `NotBoolable`; its `__bool__` method isn't callable
|
```

View file

@ -0,0 +1,53 @@
---
source: crates/red_knot_test/src/lib.rs
expression: snapshot
---
---
mdtest name: membership_test.md - Comparison: Membership Test - Return type that doesn't implement `__bool__` correctly
mdtest path: crates/red_knot_python_semantic/resources/mdtest/comparison/instances/membership_test.md
---
# Python source files
## mdtest_snippet.py
```
1 | class NotBoolable:
2 | __bool__ = 3
3 |
4 | class WithContains:
5 | def __contains__(self, item) -> NotBoolable:
6 | return NotBoolable()
7 |
8 | # error: [unsupported-bool-conversion]
9 | 10 in WithContains()
10 | # error: [unsupported-bool-conversion]
11 | 10 not in WithContains()
```
# Diagnostics
```
error: lint:unsupported-bool-conversion
--> /src/mdtest_snippet.py:9:1
|
8 | # error: [unsupported-bool-conversion]
9 | 10 in WithContains()
| ^^^^^^^^^^^^^^^^^^^^ Boolean conversion is unsupported for type `NotBoolable`; its `__bool__` method isn't callable
10 | # error: [unsupported-bool-conversion]
11 | 10 not in WithContains()
|
```
```
error: lint:unsupported-bool-conversion
--> /src/mdtest_snippet.py:11:1
|
9 | 10 in WithContains()
10 | # error: [unsupported-bool-conversion]
11 | 10 not in WithContains()
| ^^^^^^^^^^^^^^^^^^^^^^^^ Boolean conversion is unsupported for type `NotBoolable`; its `__bool__` method isn't callable
|
```

View file

@ -0,0 +1,33 @@
---
source: crates/red_knot_test/src/lib.rs
expression: snapshot
---
---
mdtest name: not.md - Unary not - Object that implements `__bool__` incorrectly
mdtest path: crates/red_knot_python_semantic/resources/mdtest/unary/not.md
---
# Python source files
## mdtest_snippet.py
```
1 | class NotBoolable:
2 | __bool__ = 3
3 |
4 | # error: [unsupported-bool-conversion]
5 | not NotBoolable()
```
# Diagnostics
```
error: lint:unsupported-bool-conversion
--> /src/mdtest_snippet.py:5:1
|
4 | # error: [unsupported-bool-conversion]
5 | not NotBoolable()
| ^^^^^^^^^^^^^^^^^ Boolean conversion is unsupported for type `NotBoolable`; its `__bool__` method isn't callable
|
```

View file

@ -0,0 +1,60 @@
---
source: crates/red_knot_test/src/lib.rs
expression: snapshot
---
---
mdtest name: rich_comparison.md - Comparison: Rich Comparison - Chained comparisons with objects that don't implement `__bool__` correctly
mdtest path: crates/red_knot_python_semantic/resources/mdtest/comparison/instances/rich_comparison.md
---
# Python source files
## mdtest_snippet.py
```
1 | class NotBoolable:
2 | __bool__ = 3
3 |
4 | class Comparable:
5 | def __lt__(self, item) -> NotBoolable:
6 | return NotBoolable()
7 |
8 | def __gt__(self, item) -> NotBoolable:
9 | return NotBoolable()
10 |
11 | # error: [unsupported-bool-conversion]
12 | 10 < Comparable() < 20
13 | # error: [unsupported-bool-conversion]
14 | 10 < Comparable() < Comparable()
15 |
16 | Comparable() < Comparable() # fine
```
# Diagnostics
```
error: lint:unsupported-bool-conversion
--> /src/mdtest_snippet.py:12:1
|
11 | # error: [unsupported-bool-conversion]
12 | 10 < Comparable() < 20
| ^^^^^^^^^^^^^^^^^ Boolean conversion is unsupported for type `NotBoolable`; its `__bool__` method isn't callable
13 | # error: [unsupported-bool-conversion]
14 | 10 < Comparable() < Comparable()
|
```
```
error: lint:unsupported-bool-conversion
--> /src/mdtest_snippet.py:14:1
|
12 | 10 < Comparable() < 20
13 | # error: [unsupported-bool-conversion]
14 | 10 < Comparable() < Comparable()
| ^^^^^^^^^^^^^^^^^ Boolean conversion is unsupported for type `NotBoolable`; its `__bool__` method isn't callable
15 |
16 | Comparable() < Comparable() # fine
|
```

View file

@ -0,0 +1,47 @@
---
source: crates/red_knot_test/src/lib.rs
expression: snapshot
---
---
mdtest name: tuples.md - Comparison: Tuples - Chained comparisons with elements that incorrectly implement `__bool__`
mdtest path: crates/red_knot_python_semantic/resources/mdtest/comparison/tuples.md
---
# Python source files
## mdtest_snippet.py
```
1 | class NotBoolable:
2 | __bool__ = 5
3 |
4 | class Comparable:
5 | def __lt__(self, other) -> NotBoolable:
6 | return NotBoolable()
7 |
8 | def __gt__(self, other) -> NotBoolable:
9 | return NotBoolable()
10 |
11 | a = (1, Comparable())
12 | b = (1, Comparable())
13 |
14 | # error: [unsupported-bool-conversion]
15 | a < b < b
16 |
17 | a < b # fine
```
# Diagnostics
```
error: lint:unsupported-bool-conversion
--> /src/mdtest_snippet.py:15:1
|
14 | # error: [unsupported-bool-conversion]
15 | a < b < b
| ^^^^^ Boolean conversion is unsupported for type `NotBoolable | Literal[False]`; its `__bool__` method isn't callable
16 |
17 | a < b # fine
|
```

View file

@ -0,0 +1,37 @@
---
source: crates/red_knot_test/src/lib.rs
expression: snapshot
---
---
mdtest name: tuples.md - Comparison: Tuples - Equality with elements that incorrectly implement `__bool__`
mdtest path: crates/red_knot_python_semantic/resources/mdtest/comparison/tuples.md
---
# Python source files
## mdtest_snippet.py
```
1 | class A:
2 | def __eq__(self, other) -> NotBoolable:
3 | return NotBoolable()
4 |
5 | class NotBoolable:
6 | __bool__ = None
7 |
8 | # error: [unsupported-bool-conversion]
9 | (A(),) == (A(),)
```
# Diagnostics
```
error: lint:unsupported-bool-conversion
--> /src/mdtest_snippet.py:9:1
|
8 | # error: [unsupported-bool-conversion]
9 | (A(),) == (A(),)
| ^^^^^^^^^^^^^^^^ Boolean conversion is unsupported for type `NotBoolable`; its `__bool__` method isn't callable
|
```

View file

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

View file

@ -183,12 +183,11 @@ class WithBothLenAndBool2:
# revealed: Literal[False]
reveal_type(not WithBothLenAndBool2())
# TODO: raise diagnostic when __bool__ method is not valid: [unsupported-operator] "Method __bool__ for type `MethodBoolInvalid` should return `bool`, returned type `int`"
# https://docs.python.org/3/reference/datamodel.html#object.__bool__
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"
# revealed: bool
reveal_type(not MethodBoolInvalid())
@ -204,3 +203,15 @@ class PossiblyUnboundBool:
# revealed: bool
reveal_type(not PossiblyUnboundBool())
```
## Object that implements `__bool__` incorrectly
<!-- snapshot-diagnostics -->
```py
class NotBoolable:
__bool__ = 3
# error: [unsupported-bool-conversion]
not NotBoolable()
```