Skip to content

Conversation

@AlexWaygood
Copy link
Member

@AlexWaygood AlexWaygood commented Jul 11, 2025

Summary

This PR (a collaboration with @ntBre) extends our existing __len__ and __bool__ special casing so that it also works with tuple subclasses. I.e., for a class like Foo here, we will now understand that instances of Foo are always truthy, and that calling len() on an instance of Foo will always return Literal[1]:

class Foo(tuple[int]): ...

The approach taken is to synthesize __bool__ and __len__ class attributes on the generic alias object tuple[int]. tuple[int].__bool__ is inferred as a function-like Callable type with signature (self: tuple[int], /) -> Literal[True]; a similar approach is taken for tuple.__len__. This means that the correct signature for these methods is naturally inherited by subclasses; it means that protocol subtyping will work correctly for these subclasses; and it means that once we have implemented the Liskov Substitution Principle, unsound overrides of these methods on tuple subclasses will be naturally detected by ty.

Test Plan

Mdtests


Co-authored-by: Brent Westbrook 36778786+ntBre@users.noreply.github.com

@AlexWaygood AlexWaygood added the ty Multi-file analysis & type inference label Jul 11, 2025
@github-actions
Copy link
Contributor

github-actions bot commented Jul 11, 2025

mypy_primer results

No ecosystem changes detected ✅
No memory usage changes detected ✅

Comment on lines +107 to +108
def __bool__(self) -> Literal[True]:
return True
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this not also be a Liskov violation?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay i guess its not a Liskov violation, but should we not complain at this?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah sorry I retract this, my bad.

Copy link
Contributor

@MatthewMckee4 MatthewMckee4 Jul 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay changed my mind slightly

def foo(x: tuple[int, ...]):
    if x:
        # Should we not also be able to say len(x) != 0
        # *This should pass
        assert len(x) != 0

foo(Baz(()))

In rust if you have a len function clippy tells you to have an is_empty function and auto generates to self.len() == 0.

This maybe isn't relevant, but it seems like we shouldn't allow override of just __bool__. I'm not sure where else to go with this to be honest.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, I think that's a great point! It seems like in general, as part of our Liskov implementation, we should probably enforce that __bool__ and __len__ are never overridden to be inconsistent with each other on any Sequence subtype. I think otherwise this could indeed cause us to make incorrect assumptions, not just regarding tuples

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cool thanks, so in this case we could emit a diagnostic saying that there should be a ’len’ method that returns ’int & ~Literal[0]’ (or something like that).

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, not sure about that specifically -- I guess I'm not sure we should emit an error on this specific tuple subclass. It seems a bit pedantic -- just because the __len__ method is annotated as returning int doesn't mean that the class is violating the contract of Sequences. It just means that we haven't been given enough information to verify; I think it's probably better to steer clear of false positives in that situation and assume the user knows what they're doing. It's not the goal of ty to catch every possible error that a user could make.

But I do think it's worth emitting errors on these classes, because here we do have enough information to say that __len__ and __bool__ are inconsistent with each other:

from collections import Sequence
from typing import Literal

class Foo(Sequence[int]):
    def __len__(self) -> Literal[0]:
        return 0

    def __bool__(self) -> Literal[True]:
        return True

class Bar(Sequence[int]):
    def __len__(self) -> Literal[3]:
        return 3

    def __bool__(self) -> Literal[False]:
        return False

Even here, though, it's questionable whether this is a typing error or more something that's in the domain of a type-aware linter to check. (That doesn't mean that it wouldn't be a useful rule -- I think it would! But it might not be in the core purview of ty right now -- it might be more something for the future, when we start to use ty to power type-aware lint rules in Ruff.)

So I guess I feel like you're raising a great point in general, but that for these specific cases we probably shouldn't emit errors, and even if we should it maybe shouldn't be ty itself that emits these errors but maybe a type-aware linter built on top of ty :-)

@sharkdp sharkdp removed their request for review July 14, 2025 07:14
Copy link
Contributor

@sharkdp sharkdp left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Very nice!

@AlexWaygood AlexWaygood force-pushed the alex-brent/tuple-length branch from 0cae67b to a92ea02 Compare July 21, 2025 12:47
@AlexWaygood AlexWaygood enabled auto-merge (squash) July 21, 2025 12:47
@AlexWaygood AlexWaygood merged commit c2380fa into main Jul 21, 2025
36 checks passed
@AlexWaygood AlexWaygood deleted the alex-brent/tuple-length branch July 21, 2025 12:50
dcreager added a commit that referenced this pull request Jul 21, 2025
* main: (25 commits)
  [ty] Sync vendored typeshed stubs (#19461)
  [ty] Extend tuple `__len__` and `__bool__` special casing to also cover tuple subclasses (#19289)
  [ty] bump docstring-adder pin (#19458)
  [ty] Disallow assignment to `Final` class attributes (#19457)
  Update dependency ruff to v0.12.4 (#19442)
  Update pre-commit hook astral-sh/ruff-pre-commit to v0.12.4 (#19443)
  Update rui314/setup-mold digest to 702b190 (#19441)
  Update taiki-e/install-action action to v2.56.19 (#19448)
  Update Rust crate strum_macros to v0.27.2 (#19447)
  Update Rust crate strum to v0.27.2 (#19446)
  Update Rust crate rand to v0.9.2 (#19444)
  Update Rust crate serde_json to v1.0.141 (#19445)
  Fix `unreachable` panic in parser (#19183)
  [`ruff`] Support byte strings (`RUF055`) (#18926)
  [ty] Avoid second lookup for `infer_maybe_standalone_expression` (#19439)
  [ty] Implemented "go to definition" support for import statements (#19428)
  [ty] Avoid secondary tree traversal to get call expression for keyword arguments (#19429)
  [ty] Add goto definition to playground (#19425)
  [ty] Add support for `@warnings.deprecated` (#19376)
  [ty] make `del x` force local resolution of `x` in the current scope (#19389)
  ...
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ty Multi-file analysis & type inference

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants