Skip to content

Conversation

AlexWaygood
Copy link
Member

@AlexWaygood AlexWaygood commented Jul 22, 2025

Summary

This PR adds synthesized __getitem__ overloads for tuple subclasses, such that for f in the following example, we can infer that f[0] evaluates to int and f[1] evaluates to str:

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

f = Foo((42, "foo"))
reveal_type(f[0])
reveal_type(f[1])

It was initially my hope when embarking on this PR that we would be able to use these synthesized overloads to fully get rid of the special casing we have for tuples in infer_subscript_expression_types. Over the course of writing the PR, I realised that this would not be possible, for the following reasons:

  1. The special casing for slice literals is too complicated to be reasonably implemented via synthesized overloads.

    The synthesized overloads being added for int literals in this PR are already (I believe) the most complicated synthesized functions we have anywhere in our codebase so far. The synthesized overloads required to support slice literals would be far more complicated. It might be theoretically possible to generate them, but I think the inherent complexity makes this realistically untenable. It could also cause performance problems to synthesize that many overloads.

  2. The index-out-of-bounds error can't be implemented via synthesized overloads.

    I'd like to explore generalising this diagnostic so that we don't just emit it for specific Type variants that we know to have fixed lengths, but for any type where Type::len() returns Some(). That's for another PR, however.

  3. For a tuple type like tuple[int, *tuple[str, ...], bytes], it's impossible to express "if it's subscripted with any literal integer higher than 1, you should infer str | bytes rather than int | str | bytes using synthesized overloads.

    This is currently implemented in our tuple special-casing, and it would be a shame to introduce a regression on this.

Because of these issues, I've come to the conclusion that we will not be able to get rid of the hardcoded special casing for tuples in TypeInferenceBuilder::infer_suscript_expression_types, and that we will probably have to extend it so that it also applies to tuple subclasses. Nonetheless, I'm opening this PR anyway, because inferring precise signatures for __getitem__ attributes on specialised tuples has advantages even if we don't end up using these signatures directly when inferring the types of subscript expressions against tuples. A good example of why is protocol assignability: it would be ideal if Bar is understood by ty as a subtype of Proto in the following example:

from typing import Protocol, Literal

class Proto(Protocol):
    def __getitem__(self, index: Literal[0], /) -> int: ...

class Bar(tuple[int, str]): ...

We currently say that Bar is indeed a subtype of Proto, but (if we do not land something similar to this PR), that will no longer be the case after fixing astral-sh/ty#889. After fixing that issue, we will strictly validate the signature of a class's method against the signature of a protocol it claims to be an instance of. Without this PR, we would look up the __getitem__ signature on tuple[int] and fallback to the generic __getitem__ signature in typeshed, which would lead us to incorrectly infer that Bar is not a subtype of Proto. With this PR, however, we should have the necessary pieces in place that we continue to consider Bar a subtype of Proto even after #889 has been fixed, because the lookup of __getitem__ on the Bar class object would return the precise synthesized overloads being added here.

Test Plan

Mdtests. The ecosystem hits also LGTM. They use os.stat() and pwd.getpwuid(), both of which return instances of tuple subclasses.

@MichaReiser MichaReiser added the ty Multi-file analysis & type inference label Jul 22, 2025
Copy link
Contributor

github-actions bot commented Jul 22, 2025

mypy_primer results

Changes were detected when running on open source projects
paasta (https://github.com/yelp/paasta)
- paasta_tools/utils.py:3111:12: error[invalid-return-type] Return type does not match returned value: expected `str`, found `str | int`
- Found 885 diagnostics
+ Found 884 diagnostics

cloud-init (https://github.com/canonical/cloud-init)
- tests/unittests/sources/test_smartos.py:560:32: error[invalid-argument-type] Argument to function `oct` is incorrect: Expected `SupportsIndex`, found `int | float`
- tests/unittests/sources/test_smartos.py:576:32: error[invalid-argument-type] Argument to function `oct` is incorrect: Expected `SupportsIndex`, found `int | float`
- tests/unittests/sources/test_smartos.py:632:35: error[invalid-argument-type] Argument to function `oct` is incorrect: Expected `SupportsIndex`, found `int | float`
- Found 599 diagnostics
+ Found 596 diagnostics

cwltool (https://github.com/common-workflow-language/cwltool)
- cwltool/cwlprov/__init__.py:16:20: warning[possibly-unbound-attribute] Attribute `split` on type `str | int` is possibly unbound
- Found 127 diagnostics
+ Found 126 diagnostics

scipy (https://github.com/scipy/scipy)
- scipy/_lib/_util.py:310:23: error[call-non-callable] Method `__getitem__` of type `Overload[(key: SupportsIndex, /) -> Unknown, (key: slice[Any, Any, Any], /) -> tuple[Unknown, ...]]` is not callable on object of type `tuple[Unknown, ...]`
+ scipy/_lib/_util.py:310:23: error[call-non-callable] Method `__getitem__` of type `Overload[(index: SupportsIndex, /) -> Unknown, (index: slice[Any, Any, Any], /) -> tuple[Unknown, ...]]` is not callable on object of type `tuple[Unknown, ...]`
- scipy/optimize/tests/test_chandrupatla.py:687:9: error[call-non-callable] Method `__getitem__` of type `Overload[(key: SupportsIndex, /) -> Unknown, (key: slice[Any, Any, Any], /) -> tuple[Unknown, ...]]` is not callable on object of type `tuple[Unknown]`
+ scipy/optimize/tests/test_chandrupatla.py:687:9: error[call-non-callable] Method `__getitem__` of type `Overload[(index: SupportsIndex, /) -> Unknown, (index: slice[Any, Any, Any], /) -> tuple[Unknown, ...]]` is not callable on object of type `tuple[Unknown]`
- scipy/optimize/tests/test_chandrupatla.py:691:9: error[call-non-callable] Method `__getitem__` of type `Overload[(key: SupportsIndex, /) -> Unknown, (key: slice[Any, Any, Any], /) -> tuple[Unknown, ...]]` is not callable on object of type `tuple[Unknown]`
+ scipy/optimize/tests/test_chandrupatla.py:691:9: error[call-non-callable] Method `__getitem__` of type `Overload[(index: SupportsIndex, /) -> Unknown, (index: slice[Any, Any, Any], /) -> tuple[Unknown, ...]]` is not callable on object of type `tuple[Unknown]`
- scipy/optimize/tests/test_chandrupatla.py:699:9: error[call-non-callable] Method `__getitem__` of type `Overload[(key: SupportsIndex, /) -> Unknown, (key: slice[Any, Any, Any], /) -> tuple[Unknown, ...]]` is not callable on object of type `tuple[Unknown]`
+ scipy/optimize/tests/test_chandrupatla.py:699:9: error[call-non-callable] Method `__getitem__` of type `Overload[(index: SupportsIndex, /) -> Unknown, (index: slice[Any, Any, Any], /) -> tuple[Unknown, ...]]` is not callable on object of type `tuple[Unknown]`
- scipy/optimize/tests/test_chandrupatla.py:702:9: error[call-non-callable] Method `__getitem__` of type `Overload[(key: SupportsIndex, /) -> Unknown, (key: slice[Any, Any, Any], /) -> tuple[Unknown, ...]]` is not callable on object of type `tuple[Unknown]`
+ scipy/optimize/tests/test_chandrupatla.py:702:9: error[call-non-callable] Method `__getitem__` of type `Overload[(index: SupportsIndex, /) -> Unknown, (index: slice[Any, Any, Any], /) -> tuple[Unknown, ...]]` is not callable on object of type `tuple[Unknown]`
- scipy/optimize/tests/test_chandrupatla.py:710:9: error[call-non-callable] Method `__getitem__` of type `Overload[(key: SupportsIndex, /) -> Unknown, (key: slice[Any, Any, Any], /) -> tuple[Unknown, ...]]` is not callable on object of type `tuple[Unknown]`
+ scipy/optimize/tests/test_chandrupatla.py:710:9: error[call-non-callable] Method `__getitem__` of type `Overload[(index: SupportsIndex, /) -> Unknown, (index: slice[Any, Any, Any], /) -> tuple[Unknown, ...]]` is not callable on object of type `tuple[Unknown]`
- scipy/optimize/tests/test_chandrupatla.py:713:9: error[call-non-callable] Method `__getitem__` of type `Overload[(key: SupportsIndex, /) -> Unknown, (key: slice[Any, Any, Any], /) -> tuple[Unknown, ...]]` is not callable on object of type `tuple[Unknown]`
+ scipy/optimize/tests/test_chandrupatla.py:713:9: error[call-non-callable] Method `__getitem__` of type `Overload[(index: SupportsIndex, /) -> Unknown, (index: slice[Any, Any, Any], /) -> tuple[Unknown, ...]]` is not callable on object of type `tuple[Unknown]`
- scipy/optimize/tests/test_chandrupatla.py:719:9: error[call-non-callable] Method `__getitem__` of type `Overload[(key: SupportsIndex, /) -> Unknown, (key: slice[Any, Any, Any], /) -> tuple[Unknown, ...]]` is not callable on object of type `tuple[Unknown]`
+ scipy/optimize/tests/test_chandrupatla.py:719:9: error[call-non-callable] Method `__getitem__` of type `Overload[(index: SupportsIndex, /) -> Unknown, (index: slice[Any, Any, Any], /) -> tuple[Unknown, ...]]` is not callable on object of type `tuple[Unknown]`
- scipy/optimize/tests/test_chandrupatla.py:724:9: error[call-non-callable] Method `__getitem__` of type `Overload[(key: SupportsIndex, /) -> Unknown, (key: slice[Any, Any, Any], /) -> tuple[Unknown, ...]]` is not callable on object of type `tuple[Unknown]`
+ scipy/optimize/tests/test_chandrupatla.py:724:9: error[call-non-callable] Method `__getitem__` of type `Overload[(index: SupportsIndex, /) -> Unknown, (index: slice[Any, Any, Any], /) -> tuple[Unknown, ...]]` is not callable on object of type `tuple[Unknown]`
No memory usage changes detected ✅

@AlexWaygood AlexWaygood force-pushed the alex/tuple-getitem branch 2 times, most recently from aa99e4f to 1d950c9 Compare July 25, 2025 17:44
Copy link
Contributor

github-actions bot commented Jul 29, 2025

Diagnostic diff on typing conformance tests

Changes were detected when running ty on typing conformance tests
--- old-output.txt	2025-07-30 11:24:12.533913338 +0000
+++ new-output.txt	2025-07-30 11:24:12.595913773 +0000
@@ -87,6 +87,7 @@
 aliases_variance.py:18:24: error[non-subscriptable] Cannot subscript object of type `<class 'ClassA[T_co]'>` with no `__class_getitem__` method
 aliases_variance.py:28:16: error[non-subscriptable] Cannot subscript object of type `<class 'ClassA[T_co]'>` with no `__class_getitem__` method
 aliases_variance.py:44:16: error[non-subscriptable] Cannot subscript object of type `<class 'ClassB[T_co, T_contra]'>` with no `__class_getitem__` method
+annotations_coroutines.py:27:5: error[type-assertion-failure] Argument does not have asserted type `str`
 annotations_forward_refs.py:22:7: error[unresolved-reference] Name `ClassA` used when not defined
 annotations_forward_refs.py:23:12: error[unresolved-reference] Name `ClassA` used when not defined
 annotations_forward_refs.py:49:10: error[invalid-type-form] Variable of type `Literal[1]` is not allowed in a type expression
@@ -101,6 +102,8 @@
 annotations_forward_refs.py:96:1: error[type-assertion-failure] Argument does not have asserted type `int`
 annotations_generators.py:86:21: error[invalid-return-type] Return type does not match returned value: expected `int`, found `types.GeneratorType`
 annotations_generators.py:91:27: error[invalid-return-type] Return type does not match returned value: expected `int`, found `types.AsyncGeneratorType`
+annotations_generators.py:167:5: error[type-assertion-failure] Argument does not have asserted type `AsyncGenerator[str, None]`
+annotations_generators.py:174:5: error[type-assertion-failure] Argument does not have asserted type `AsyncGenerator[str, None]`
 annotations_generators.py:193:1: error[type-assertion-failure] Argument does not have asserted type `() -> AsyncIterator[int]`
 annotations_methods.py:31:1: error[type-assertion-failure] Argument does not have asserted type `A`
 annotations_methods.py:36:1: error[type-assertion-failure] Argument does not have asserted type `B`
@@ -889,4 +892,4 @@
 tuples_type_form.py:36:1: error[invalid-assignment] Object of type `tuple[Literal[1], Literal[2], Literal[3], Literal[""]]` is not assignable to `tuple[int, ...]`
 typeddicts_operations.py:60:1: error[type-assertion-failure] Argument does not have asserted type `str | None`
 typeddicts_type_consistency.py:101:1: error[invalid-assignment] Object of type `Unknown | None` is not assignable to `str`
-Found 890 diagnostics
+Found 893 diagnostics

@AlexWaygood AlexWaygood marked this pull request as ready for review July 29, 2025 17:56
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 very much.

This is extremely cool. It makes me a little sad, because the protocol matching seems to be the only effect of this once we add the special-casing logic in infer_suscript_expression_types for tuple subclasses. And I'm not really convinced that this is a real world concern?

If it is a real world concern, then why do we do this for tuple sublcasses only? Shouldn't the same logic apply to normal tuples as well?

Precise types for index operations are also inferred for tuple subclasses:

```py
class HeterogeneousSubclass(tuple[int, str, int, bytes]): ...
Copy link
Contributor

Choose a reason for hiding this comment

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

Here, and in the Rust source code, I would find it much easier to have type names that I could easily associated with the index at which they appear.

Also, having worked on slicing/index math before, I know that it's easy to run into integer overflows and other fun issues, so I would appreciate if we could also add tests for lengths 0-2.

So I would probably start with a set of tests like:

class I0: ...
class I1: ...
class I2: ...

class HeterogeneousSubclass0(tuple[()]): ...

# revealed: Overload[(self, index: SupportsIndex, /) -> Never, (self, index: slice[Any, Any, Any], /) -> tuple[()]]
reveal_type(HeterogeneousSubclass0.__getitem__)


class HeterogeneousSubclass1(tuple[I0]): ...

# revealed: Overload[(self, index: SupportsIndex, /) -> I0, (self, index: slice[Any, Any, Any], /) -> tuple[I0, ...]]
reveal_type(HeterogeneousSubclass1.__getitem__)


class HeterogeneousSubclass2(tuple[I0, I1]): ...

# revealed: Overload[(self, index: Literal[-2, 0], /) -> I0, (self, index: Literal[-1, 1], /) -> I1, (self, index: SupportsIndex, /) -> I0 | I1, (self, index: slice[Any, Any, Any], /) -> tuple[I0 | I1, ...]]
reveal_type(HeterogeneousSubclass2.__getitem__)


class HeterogeneousSubclass3(tuple[I0, I1, I2]): ...

# revealed: Overload[(self, index: Literal[-3, 0], /) -> I0, (self, index: Literal[-2, 1], /) -> I1, (self, index: Literal[-1, 2], /) -> I2, (self, index: SupportsIndex, /) -> I0 | I1 | I2, (self, index: slice[Any, Any, Any], /) -> tuple[I0 | I1 | I2, ...]]
reveal_type(HeterogeneousSubclass3.__getitem__)

In fact, it's interesting to see the differences between HeterogeneousSubclass0 and HeterogeneousSubclass1. The first one has an overload that returns Never for all indices... which is correct. The second one has a fallback overload that returns I0 for all indices.. which is also fine, given that we'll add a index-out-of-bounds error elsewhere(?).


When I read this first test here with tuple[int, str, int, bytes] I was first slightly annoyed by the fact that it used int twice, until I realized that this is of course intended. So I think I would appreciate a short explanation:

class A: ...
class B: ...
class C: ...

# Note that the first and third elements have the same type:
class HeterogeneousSubclass(tuple[A, B, A, C]): ...

# revealed: Overload[(self, index: Literal[-4, -2, 0, 2], /) -> A, (self, index: Literal[-3, 1], /) -> B, (self, index: Literal[-1, 3], /) -> C, (self, index: SupportsIndex, /) -> A | B | C, (self, index: slice[Any, Any, Any], /) -> tuple[A | B | C, ...]]
reveal_type(HeterogeneousSubclass.__getitem__)


class MixedSubclass(tuple[Exception, *tuple[str, ...], int, bytes, int, range]): ...

# revealed: Overload[(self, index: Literal[-3], /) -> bytes, (self, index: Literal[4], /) -> str | int | bytes | range, (self, index: Literal[2, 3], /) -> str | int | bytes, (self, index: Literal[-1], /) -> range, (self, index: Literal[1], /) -> str | int, (self, index: Literal[-5], /) -> str | Exception, (self, index: Literal[-4, -2], /) -> int, (self, index: Literal[0], /) -> Exception, (self, index: SupportsIndex, /) -> Exception | str | int | bytes | range, (self, index: slice[Any, Any, Any], /) -> tuple[Exception | str | int | bytes | range, ...]]
Copy link
Contributor

Choose a reason for hiding this comment

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

I understand that we "only" generate O(tuple_length) overloads here. But I wonder if this could still lead to performance problems for large lengths, given that overload resolution will have to work with this huge set of overloads? I guess it's fine and we probably don't even need a benchmark for this, as tuple subclasses of extremely large tuples are hopefully not existent?

import stat

reveal_type(os.stat("my_file.txt")[stat.ST_MODE]) # revealed: int
# revealed: Overload[(self, index: Literal[-10, -9, -8, -7, -6, -5, -4, 0, 1, 2, 3, 4, 5, 6], /) -> int, (self, index: SupportsIndex, /) -> int | float, (self, index: slice[Any, Any, Any], /) -> tuple[int | float, ...]]
Copy link
Contributor

Choose a reason for hiding this comment

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

stat_result is based on tuple[int, int, int, int, int, int, int, float, float, float], so why don't we see the special overload for float here? Because float splits into int | float? And int | float is also equal to the union of all element types? Makes sense... but maybe worth a comment? Or is there another stdlib API that we could use as a more interesting example?

Copy link
Member Author

Choose a reason for hiding this comment

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

I think stat_result is a good one to test here, as there are several hits in the mypy_primer diff showing that people often index into it with an index <=6 and expect the type checker to understand that the result is an int. But I'll add a comment -- and I can add some tests with "more heterogeneous" stdlib APIs too!

@AlexWaygood
Copy link
Member Author

If it is a real world concern, then why do we do this for tuple sublcasses only? Shouldn't the same logic apply to normal tuples as well?

I should have been clearer in my PR description. While the original motivation for trying this out was to support tuple subclasses, we synthesize these overloads for the tuple type as well (not just subclasses of the tuple type), so tuple[int, str] will also be understood as a subtype of the Proto class in my PR description.

I should probably add tests for these specifically, to ensure they don't unexpectedly break when we're further along in our protocols implementation.

Co-authored-by: David Peter <sharkdp@users.noreply.github.com>
@AlexWaygood
Copy link
Member Author

This is extremely cool. It makes me a little sad, because the protocol matching seems to be the only effect of this once we add the special-casing logic in infer_suscript_expression_types for tuple subclasses. And I'm not really convinced that this is a real world concern?

Yes, I was in two minds after finishing this whether it was worth the added complexity or not. I'm still not totally sure that it is. How often do people try to use tuples (or tuple subtypes) as subtypes of protocols? I'm not sure -- though I also think I'd find it surprising if I tried this as a user, and found that it didn't work!

As a general principle, I think we should try to implement as much of our per-type special casing as possible via synthesized methods on the class object (where it would be sensible to do so) rather than in places like TypeInferenceBuilder::infer_subscript_expression_types or Type::bindings(). Doing it via synthesized methods means you get a sensible type when you access the dunder directly from the class object as well as from instances; it means that protocol assignability (which uses the member access machinery) will "just work"; it'll mean that our Liskov implementation (once it's landed) will be able to accurately check for compatibility of overriding methods; and it'll mean that the special casing added in the synthesized method will naturally carry over to subclasses. Adding these synthesized methods for tuples moves us closer to that general goal.

It may also still be possible to reduce the special casing we have in infer_subscript_expression_types in the future. If we land this and generalize the index-out-of-bounds error as I suggest in my PR description here, the only remaining piece that we'd need to special-case for tuples in that method would be the special casing for slice expressions.

I chatted with Carl on Friday and he was generally supportive of the idea of something like this PR, despite the caveats. That said, I don't think he's taken a look at the actual PR branch.

Overall I still weakly lean towards landing this, especially since the code it's adding is all very localised (it'll be easy to rip it out later if we end up deciding it's really not that useful after all).

@AlexWaygood AlexWaygood enabled auto-merge (squash) July 30, 2025 11:20
@AlexWaygood AlexWaygood merged commit feaedb1 into main Jul 30, 2025
36 of 37 checks passed
@AlexWaygood AlexWaygood deleted the alex/tuple-getitem branch July 30, 2025 11:25
@dcreager
Copy link
Member

As a general principle, I think we should try to implement as much of our per-type special casing as possible via synthesized methods on the class object (where it would be sensible to do so) rather than in places like TypeInferenceBuilder::infer_subscript_expression_types or Type::bindings().

I strongly agree with this

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