[ty] Extend tuple __len__ and __bool__ special casing to also cover tuple subclasses (#19289)

Co-authored-by: Brent Westbrook
This commit is contained in:
Alex Waygood 2025-07-21 13:50:46 +01:00 committed by GitHub
parent 4dec44ae49
commit c2380fa0e2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 194 additions and 16 deletions

View file

@ -72,7 +72,14 @@ reveal_type(my_bool(0)) # revealed: bool
## Truthy values
```toml
[environment]
python-version = "3.11"
```
```py
from typing import Literal
reveal_type(bool(1)) # revealed: Literal[True]
reveal_type(bool((0,))) # revealed: Literal[True]
reveal_type(bool("NON EMPTY")) # revealed: Literal[True]
@ -81,6 +88,42 @@ reveal_type(bool(True)) # revealed: Literal[True]
def foo(): ...
reveal_type(bool(foo)) # revealed: Literal[True]
class SingleElementTupleSubclass(tuple[int]): ...
reveal_type(bool(SingleElementTupleSubclass((0,)))) # revealed: Literal[True]
reveal_type(SingleElementTupleSubclass.__bool__) # revealed: (self: tuple[int], /) -> Literal[True]
reveal_type(SingleElementTupleSubclass().__bool__) # revealed: () -> Literal[True]
# Unknown length, but we know the length is guaranteed to be >=2
class MixedTupleSubclass(tuple[int, *tuple[str, ...], bytes]): ...
reveal_type(bool(MixedTupleSubclass((1, b"foo")))) # revealed: Literal[True]
reveal_type(MixedTupleSubclass.__bool__) # revealed: (self: tuple[int, *tuple[str, ...], bytes], /) -> Literal[True]
reveal_type(MixedTupleSubclass().__bool__) # revealed: () -> Literal[True]
# Unknown length with an overridden `__bool__`:
class VariadicTupleSubclassWithDunderBoolOverride(tuple[int, ...]):
def __bool__(self) -> Literal[True]:
return True
reveal_type(bool(VariadicTupleSubclassWithDunderBoolOverride((1,)))) # revealed: Literal[True]
reveal_type(VariadicTupleSubclassWithDunderBoolOverride.__bool__) # revealed: def __bool__(self) -> Literal[True]
# revealed: bound method VariadicTupleSubclassWithDunderBoolOverride.__bool__() -> Literal[True]
reveal_type(VariadicTupleSubclassWithDunderBoolOverride().__bool__)
# Same again but for a subclass of a fixed-length tuple:
class EmptyTupleSubclassWithDunderBoolOverride(tuple[()]):
# TODO: we should reject this override as a Liskov violation:
def __bool__(self) -> Literal[True]:
return True
reveal_type(bool(EmptyTupleSubclassWithDunderBoolOverride(()))) # revealed: Literal[True]
reveal_type(EmptyTupleSubclassWithDunderBoolOverride.__bool__) # revealed: def __bool__(self) -> Literal[True]
# revealed: bound method EmptyTupleSubclassWithDunderBoolOverride.__bool__() -> Literal[True]
reveal_type(EmptyTupleSubclassWithDunderBoolOverride().__bool__)
```
## Falsy values
@ -92,6 +135,12 @@ reveal_type(bool(None)) # revealed: Literal[False]
reveal_type(bool("")) # revealed: Literal[False]
reveal_type(bool(False)) # revealed: Literal[False]
reveal_type(bool()) # revealed: Literal[False]
class EmptyTupleSubclass(tuple[()]): ...
reveal_type(bool(EmptyTupleSubclass())) # revealed: Literal[False]
reveal_type(EmptyTupleSubclass.__bool__) # revealed: (self: tuple[()], /) -> Literal[False]
reveal_type(EmptyTupleSubclass().__bool__) # revealed: () -> Literal[False]
```
## Ambiguous values
@ -100,6 +149,13 @@ reveal_type(bool()) # revealed: Literal[False]
reveal_type(bool([])) # revealed: bool
reveal_type(bool({})) # revealed: bool
reveal_type(bool(set())) # revealed: bool
class VariadicTupleSubclass(tuple[int, ...]): ...
def f(x: tuple[int, ...], y: VariadicTupleSubclass):
reveal_type(bool(x)) # revealed: bool
reveal_type(x.__bool__) # revealed: () -> bool
reveal_type(y.__bool__) # revealed: () -> bool
```
## `__bool__` returning `NoReturn`

View file

@ -65,6 +65,51 @@ reveal_type(len((*[], 1, 2))) # revealed: Literal[3]
reveal_type(len((*[], *{}))) # revealed: Literal[2]
```
Tuple subclasses:
```py
class EmptyTupleSubclass(tuple[()]): ...
class Length1TupleSubclass(tuple[int]): ...
class Length2TupleSubclass(tuple[int, str]): ...
class UnknownLengthTupleSubclass(tuple[int, ...]): ...
reveal_type(len(EmptyTupleSubclass())) # revealed: Literal[0]
reveal_type(len(Length1TupleSubclass((1,)))) # revealed: Literal[1]
reveal_type(len(Length2TupleSubclass((1, "foo")))) # revealed: Literal[2]
reveal_type(len(UnknownLengthTupleSubclass((1, 2, 3)))) # revealed: int
reveal_type(tuple[int, int].__len__) # revealed: (self: tuple[int, int], /) -> Literal[2]
reveal_type(tuple[int, ...].__len__) # revealed: (self: tuple[int, ...], /) -> int
def f(x: tuple[int, int], y: tuple[int, ...]):
reveal_type(x.__len__) # revealed: () -> Literal[2]
reveal_type(y.__len__) # revealed: () -> int
reveal_type(EmptyTupleSubclass.__len__) # revealed: (self: tuple[()], /) -> Literal[0]
reveal_type(EmptyTupleSubclass().__len__) # revealed: () -> Literal[0]
reveal_type(UnknownLengthTupleSubclass.__len__) # revealed: (self: tuple[int, ...], /) -> int
reveal_type(UnknownLengthTupleSubclass().__len__) # revealed: () -> int
```
If `__len__` is overridden, we use the overridden return type:
```py
from typing import Literal
class UnknownLengthSubclassWithDunderLenOverridden(tuple[int, ...]):
def __len__(self) -> Literal[42]:
return 42
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]:
return 42
reveal_type(len(FixedLengthSubclassWithDunderLenOverridden((1,)))) # revealed: Literal[42]
```
### Lists, sets and dictionaries
```py

View file

@ -551,6 +551,11 @@ static_assert(is_subtype_of(Never, AlwaysFalsy))
### `AlwaysTruthy` and `AlwaysFalsy`
```toml
[environment]
python-version = "3.11"
```
```py
from ty_extensions import AlwaysTruthy, AlwaysFalsy, Intersection, Not, is_subtype_of, static_assert
from typing_extensions import Literal, LiteralString
@ -588,6 +593,30 @@ static_assert(is_subtype_of(Intersection[LiteralString, Not[Literal["", "a"]]],
static_assert(is_subtype_of(Intersection[LiteralString, Not[Literal[""]]], Not[AlwaysFalsy]))
# error: [static-assert-error]
static_assert(is_subtype_of(Intersection[LiteralString, Not[Literal["", "a"]]], Not[AlwaysFalsy]))
class Length2TupleSubclass(tuple[int, str]): ...
static_assert(is_subtype_of(Length2TupleSubclass, AlwaysTruthy))
class EmptyTupleSubclass(tuple[()]): ...
static_assert(is_subtype_of(EmptyTupleSubclass, AlwaysFalsy))
class TupleSubclassWithAtLeastLength2(tuple[int, *tuple[str, ...], bytes]): ...
static_assert(is_subtype_of(TupleSubclassWithAtLeastLength2, AlwaysTruthy))
class UnknownLength(tuple[int, ...]): ...
static_assert(not is_subtype_of(UnknownLength, AlwaysTruthy))
static_assert(not is_subtype_of(UnknownLength, AlwaysFalsy))
class Invalid(tuple[int, str]):
# TODO: we should emit an error here (Liskov violation)
def __bool__(self) -> Literal[False]:
return False
static_assert(is_subtype_of(Invalid, AlwaysFalsy))
```
### `TypeGuard` and `TypeIs`