Skip to content

Conversation

@AlexWaygood
Copy link
Member

Summary

This PR rips out the special casing for <string literal> in <tuple> that was added in #18251.

We were unsure at the time whether it was worth it to add this special casing, since it added complexity to our type inference machinery but didn't appear to remove any false positives or false negatives when checking user code (the mypy_primer report on that PR is an empty diff). At the time, we thought that the benefits outweighed the costs, however, since the added complexity wasn't very large, and it allowed us to test our new ide_support::all_members API in an elegant way in mdtests.

I think we should reconsider this, however. It's fundamentally inconsistent behaviour for us to infer the expression "foo" in ("foo",) as evaluating to Literal[True] if we do not also infer the expression "foo" in SingleElementTupleSubclass(("foo",)) as evaluating to Literal[True] (where SingleElementTupleSubclass is considered by ty to be a subtype of tuple[Literal["foo"]]). We therefore have three options:

  1. Convert the special casing to use synthesized __contains__ overloads, similar to what was done for __getitem__ in [ty] Synthesize precise __getitem__ overloads for tuple subclasses #19493
  2. Adapt the existing special casing in TypeInferenceBuilder::infer_binary_type_comparison so that it also applies to tuple subclasses
  3. Remove the special casing

(1) and (2) are both viable paths here, but they would both add significant complexity to the implementation of the special case, and I'm not sure it's worth doing that without evidence of patterns in user code that this enables us to understand better. Additionally, if we went with option (2) we would have to forbid users from overriding __contains__ on tuple subclasses in order for us to make tuple subclasses sound. If we went with option (1) we would not have to forbid overrides of __contains__ on user subclasses of tuple; but in practice, we'd make it very difficult for users to do so after our implementation of the Liskov Substitution Principle has landed. Either forbidding __contains__ overrides outright, or de-facto forbidding them by requiring the override to duplicate complicated overloads from the base class, feels overly pedantic to me. This PR therefore goes with option (3): ripping out the special casing.

The remaining question was, therefore: what to do about our all_members.md test? The solution this PR proposes is to adapt that test by adding a new ty_extensions.has_member() function that returns Literal[True] if our ide_support_all_members routine considers an object to have a particular member, and Literal[False] if not.

Test Plan

Mdtests

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

Diagnostic diff on typing conformance tests

No changes detected when running ty on typing conformance tests ✅

@github-actions
Copy link
Contributor

mypy_primer results

No ecosystem changes detected ✅
No memory usage changes detected ✅

@sharkdp

This comment was marked as resolved.

@AlexWaygood
Copy link
Member Author

AlexWaygood commented Jul 31, 2025

We discussed this in person in our 1:1 today -- for completeness, here's an (admittedly slightly contrived) example where the current special casing would lead us to infer Literal[True] even though the result at runtime is False:

from typing import Literal

class Bar(tuple[Literal["foo"]]):
    def __contains__(self, item) -> Literal[False]:
        return False

def is_foo_in_tuple(x: tuple[Literal["foo"]]) -> Literal[True]:
    return "foo" in x

reveal_type(is_foo_in_tuple(Bar(("foo",))))

We would continue to let this example pass type checking without diagnostics even after astral-sh/ty#166 is implemented, because our current special casing does not synthesize a precise __contains__ method for tuple types, it just applies the special casing directly in infer_binary_type_comparison.

Even putting aside soundness issues, it feels very strange to me that you'd get a more precise result here by upcasting a tuple subclass to its supertype (and a less precise result after narrowing the type) -- you would usually expect the opposite:

from typing import Literal

class Bar(tuple[Literal["foo"]]): ...

x = ("foo",)

reveal_type("foo" in x)  # Literal[True]

if isinstance(x, Bar):
    reveal_type("foo" in x)  # now bool, even though the type of `x` is now more precise

def upcast(x: tuple[Literal["foo"]]) -> tuple[Literal["foo"]]:
    return x

def _(x: Bar):
    reveal_type("foo" in x)  # revealed: bool
    reveal_type("foo" in upcast(x))  # Literal[True], even though the `upcast()` function makes the type of `x` less precise!

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.

Thank you

@AlexWaygood AlexWaygood merged commit 27b03a9 into main Jul 31, 2025
39 checks passed
@AlexWaygood AlexWaygood deleted the alex/tuple-contains branch July 31, 2025 10:28
dcreager added a commit that referenced this pull request Aug 1, 2025
* main: (39 commits)
  [ty] Initial test suite for `TypedDict` (#19686)
  [ty] Improve debuggability of protocol types (#19662)
  [ty] Simplify lifetime requirements for `PySlice` trait (#19687)
  [ty] Improve `isinstance()` truthiness analysis for generic types (#19668)
  [`refurb`] Make example error out-of-the-box (`FURB164`) (#19673)
  Fix link: unused_import.rs (#19648)
  [ty] Remove `Specialization::display` (full) (#19682)
  [ty] Remove `KnownModule::is_enum` (#19681)
  [ty] Support `__setitem__` and improve `__getitem__` related diagnostics (#19578)
  [ty] Sync vendored typeshed stubs (#19676)
  [`flake8-use-pathlib`] Expand `PTH201` to check all `PurePath` subclasses (#19440)
  [`refurb`] Make example error out-of-the-box (`FURB180`) (#19672)
  [`pyupgrade`] Prevent infinite loop with `I002` (`UP010`, `UP035`) (#19413)
  [ty] Improve the `Display` for generic `type[]` types (#19667)
  [ty] Refactor `TypeInferenceBuilder::infer_subscript_expression_types` (#19658)
  Fix tests on 32-bit architectures (#19652)
  [ty] Move `pandas-stubs` to bad.txt (#19659)
  [ty] Remove special casing for string-literal-in-tuple `__contains__` (#19642)
  Update pre-commit's `ruff` id (#19654)
  Update salsa (#19449)
  ...
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.

3 participants