Skip to content

[ty] Preserve Self for class-object intersection receivers#25704

Closed
charliermarsh wants to merge 25 commits into
mainfrom
charlie/preserve-self-class-object-intersections
Closed

[ty] Preserve Self for class-object intersection receivers#25704
charliermarsh wants to merge 25 commits into
mainfrom
charlie/preserve-self-class-object-intersections

Conversation

@charliermarsh

@charliermarsh charliermarsh commented Jun 6, 2026

Copy link
Copy Markdown
Member

Summary

When a classmethod is accessed through an intersection such as Intersection[type[Factory], type[Extra]], we currently find the member on one element and bind Self from that element alone. This loses the rest of the class-object intersection:

from typing import Self
from ty_extensions import Intersection

class Factory:
    @classmethod
    def make(cls) -> Self:
        return cls()

class Extra: ...

def make(cls: Intersection[type[Factory], type[Extra]]):
    reveal_type(cls.make())  # before: Factory; after: Factory & Extra

The intersected class object represents a class whose instances satisfy both constraints, so binding Self must preserve the corresponding intersection of instance types.

This builds on #25819, which addressed the post-merge review of #25626.

@astral-sh-bot astral-sh-bot Bot added the ty Multi-file analysis & type inference label Jun 6, 2026
@astral-sh-bot

astral-sh-bot Bot commented Jun 6, 2026

Copy link
Copy Markdown

Typing conformance results

No changes detected ✅

Current numbers
The percentage of diagnostics emitted that were expected errors held steady at 92.23%. The percentage of expected errors that received a diagnostic held steady at 87.42%. The number of fully passing files held steady at 92/134.

@astral-sh-bot

astral-sh-bot Bot commented Jun 6, 2026

Copy link
Copy Markdown

Memory usage report

Summary

Project Old New Diff Outcome
prefect 562.57MB 563.22MB +0.12% (668.98kB)
sphinx 207.19MB 207.37MB +0.09% (185.36kB)
trio 87.65MB 87.72MB +0.08% (68.36kB)
flake8 35.36MB 35.37MB +0.03% (12.29kB)

Significant changes

Click to expand detailed breakdown

prefect

Name Old New Diff Outcome
infer_expression_types_impl 55.21MB 55.49MB +0.51% (288.53kB)
infer_definition_types 74.04MB 74.25MB +0.28% (213.43kB)
infer_scope_types_impl 47.64MB 47.74MB +0.20% (95.43kB)
all_narrowing_constraints_for_expression 13.21MB 13.23MB +0.15% (20.52kB)
infer_unpack_types 1.01MB 1.02MB +1.40% (14.52kB)
try_call_bin_op_return_type_impl 286.82kB 295.58kB +3.06% (8.77kB)
function_known_decorators 3.92MB 3.93MB +0.10% (4.22kB)
infer_statement_types_impl 878.18kB 882.21kB +0.46% (4.03kB)
loop_header_reachability 428.27kB 432.05kB +0.88% (3.77kB)
infer_deferred_types 9.00MB 9.00MB +0.04% (3.33kB)
member_lookup_with_policy_inner 13.53MB 13.53MB +0.02% (2.91kB)
infer_expression_type_impl 390.38kB 392.32kB +0.50% (1.95kB)
StaticClassLiteral<'db>::implicit_attribute_inner_ 1.01MB 1.01MB +0.17% (1.77kB)
check_file_impl 17.81MB 17.81MB +0.01% (1.45kB)
IntersectionType 1012.11kB 1013.41kB +0.13% (1.30kB)
... 22 more

sphinx

Name Old New Diff Outcome
infer_expression_types_impl 20.49MB 20.57MB +0.41% (85.72kB)
infer_definition_types 20.21MB 20.25MB +0.21% (43.64kB)
infer_scope_types_impl 12.54MB 12.57MB +0.17% (21.42kB)
all_narrowing_constraints_for_expression 4.12MB 4.13MB +0.29% (12.18kB)
infer_unpack_types 444.65kB 452.59kB +1.78% (7.93kB)
try_call_bin_op_return_type_impl 201.35kB 209.09kB +3.84% (7.73kB)
loop_header_reachability 344.28kB 346.14kB +0.54% (1.86kB)
infer_statement_types_impl 461.46kB 463.02kB +0.34% (1.56kB)
member_lookup_with_policy_inner 5.64MB 5.64MB +0.02% (1.11kB)
IntersectionType 561.52kB 562.11kB +0.10% (600.00B)
is_redundant_with_impl::interned_arguments 1.14MB 1.15MB +0.04% (528.00B)
is_redundant_with_impl 919.73kB 920.02kB +0.03% (288.00B)
function_known_decorators 973.06kB 973.32kB +0.03% (264.00B)
StaticClassLiteral<'db>::implicit_attribute_inner_ 699.10kB 699.31kB +0.03% (216.00B)
infer_expression_type_impl 279.56kB 279.72kB +0.06% (168.00B)
... 2 more

trio

Name Old New Diff Outcome
infer_expression_types_impl 6.57MB 6.61MB +0.50% (33.96kB)
infer_definition_types 6.43MB 6.45MB +0.25% (16.70kB)
infer_scope_types_impl 3.78MB 3.78MB +0.22% (8.46kB)
all_narrowing_constraints_for_expression 1012.84kB 1015.77kB +0.29% (2.93kB)
try_call_bin_op_return_type_impl 46.30kB 48.23kB +4.15% (1.92kB)
infer_unpack_types 143.42kB 145.31kB +1.32% (1.89kB)
loop_header_reachability 126.60kB 127.56kB +0.76% (984.00B)
member_lookup_with_policy_inner 1.42MB 1.42MB +0.03% (516.00B)
infer_expression_type_impl 61.57kB 61.90kB +0.53% (336.00B)
StaticClassLiteral<'db>::implicit_attribute_inner_ 120.83kB 121.10kB +0.22% (276.00B)
infer_statement_types_impl 45.91kB 46.07kB +0.33% (156.00B)
function_known_decorators 291.49kB 291.58kB +0.03% (96.00B)
infer_deferred_types 1.78MB 1.78MB +0.01% (96.00B)
place_by_id 568.11kB 568.14kB +0.01% (36.00B)
overloads_and_implementation_inner 327.22kB 327.24kB +0.01% (24.00B)
... 2 more

flake8

Name Old New Diff Outcome
infer_expression_types_impl 974.41kB 979.49kB +0.52% (5.07kB)
infer_definition_types 1.53MB 1.53MB +0.19% (2.94kB)
infer_scope_types_impl 788.72kB 791.13kB +0.31% (2.41kB)
all_narrowing_constraints_for_expression 167.11kB 167.63kB +0.32% (540.00B)
infer_unpack_types 35.26kB 35.65kB +1.10% (396.00B)
member_lookup_with_policy_inner 405.30kB 405.64kB +0.08% (348.00B)
try_call_bin_op_return_type_impl 6.24kB 6.55kB +4.88% (312.00B)
loop_header_reachability 12.41kB 12.52kB +0.85% (108.00B)
infer_statement_types_impl 39.98kB 40.06kB +0.21% (84.00B)
function_known_decorators 146.02kB 146.09kB +0.05% (72.00B)
StaticClassLiteral<'db>::implicit_attribute_inner_ 44.69kB 44.71kB +0.05% (24.00B)
infer_expression_type_impl 13.65kB 13.67kB +0.17% (24.00B)

@astral-sh-bot

astral-sh-bot Bot commented Jun 6, 2026

Copy link
Copy Markdown

ecosystem-analyzer results

Lint rule Added Removed Changed
invalid-argument-type 0 3 0
invalid-return-type 1 1 0
Total 1 4 0

Raw diff:

Tanjun (https://github.com/FasterSpeeding/Tanjun)
- tanjun/annotations.py:1449:31 error[invalid-argument-type] Argument to `Choices.__init__` is incorrect: Argument type `_EnumT@__getitem__` does not satisfy constraints (`int`, `int | float`, `str`) of type variable `_ChoiceT`
- tanjun/annotations.py:1454:31 error[invalid-argument-type] Argument to `Choices.__init__` is incorrect: Argument type `_EnumT@__getitem__` does not satisfy constraints (`int`, `int | float`, `str`) of type variable `_ChoiceT`
- tanjun/annotations.py:1459:31 error[invalid-argument-type] Argument to `Choices.__init__` is incorrect: Argument type `_EnumT@__getitem__` does not satisfy constraints (`int`, `int | float`, `str`) of type variable `_ChoiceT`

hydpy (https://github.com/hydpy-dev/hydpy)
- hydpy/core/masktools.py:39:16 error[invalid-return-type] Return type does not match returned value: expected `Self@array2mask`, found `ndarray[tuple[Any, ...], Unknown]`

prefect (https://github.com/PrefectHQ/prefect)
+ src/prefect/input/run_input.py:900:16 error[invalid-return-type] Return type does not match returned value: expected `GetAutomaticInputHandler[T@receive_input] | GetInputHandler[R@receive_input]`, found `GetInputHandler[R@receive_input & ~Top[AutomaticRunInput[Unknown]]]`

Full report with detailed diff (timing results)

@charliermarsh charliermarsh force-pushed the charlie/preserve-self-class-object-intersections branch 2 times, most recently from 337915b to 0453acf Compare June 9, 2026 00:00
@charliermarsh charliermarsh marked this pull request as ready for review June 9, 2026 00:00
@charliermarsh charliermarsh force-pushed the charlie/preserve-self-class-object-intersections branch from fed3eae to cf7cc04 Compare June 10, 2026 13:36
@charliermarsh charliermarsh changed the base branch from main to charlie/address-intersection-receiver-review June 10, 2026 13:36
Base automatically changed from charlie/address-intersection-receiver-review to main June 10, 2026 13:37
@charliermarsh charliermarsh force-pushed the charlie/preserve-self-class-object-intersections branch from cf7cc04 to 79d22a4 Compare June 10, 2026 13:45
@AlexWaygood AlexWaygood removed their request for review June 10, 2026 13:45
@codspeed-hq

codspeed-hq Bot commented Jun 10, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 67 untouched benchmarks
⏩ 60 skipped benchmarks1


Comparing charlie/preserve-self-class-object-intersections (79d22a4) with main (13e4a58)

Open in CodSpeed

Footnotes

  1. 60 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports.

@sharkdp sharkdp left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Sorry, still not a full review yet. I'm struggling with some of the concepts here.

Comment on lines +334 to +358
## Classmethods preserve unrelated occurrences of `Self`

```py
from __future__ import annotations

from typing import Self, cast
from ty_extensions import Intersection

class Factory[T]:
@classmethod
def identity(cls, value: object) -> T:
return cast(T, value)

class Owner:
def inspect(
self: Self,
cls: Intersection[type[Factory[Self]], type[Owner]],
) -> None:
reveal_type(cls.identity(self)) # revealed: Self@inspect

class ConcreteOwner(Owner): ...
class Both(Factory[ConcreteOwner], Owner): ...

ConcreteOwner().inspect(Both)
```

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This test passes on main. Can we describe more clearly what is being tested here? Is this a regression test for something that came up during the implementation? Do we need to make sure that the Self@inspect doesn't turn into something else?

Comment on lines +321 to +323
When `T` is inferred as an intersection, `type[T]` preserves constraints on the runtime class.
Nominal class constraints survive, value and structural refinements do not, and `TypedDict`,
`NewType`, and class-object constraints project to their concrete runtime classes.

@sharkdp sharkdp Jun 9, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

For me, these introductory prose sections are often the most valuable part of an mdtest, but I have trouble understanding this. The text here also seemingly ignores the existing tests in this section.

When T is inferred as an intersection, type[T] preserves constraints on the runtime class.

What kinds of constraints?

Nominal class constraints survive, value and structural refinements do not

Are we talking about negative intersection elements? What are value and structural refinements?
Is it: ~B survives as ~type[B], but ~Literal[1] does not survive in the type -> metatype transformation?

class-object constraints project to their concrete runtime classes

?

def _(x: int):
if x != 1:
reveal_type(runtime_type(x)) # revealed: type[int]
if runtime_type(x) is int:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Why is this here? This doesn't affect the type of x and doesn't seem related to what we want to test?

Comment on lines +356 to +357
def _(x: Intersection[A, Not[B]]):
reveal_type(runtime_type(x)) # revealed: type[A] & ~type[B]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Hm, this is interesting. On your branch,runtime_type transforms A & B into type[A] & type[B]. However, for any other covariant generic class, if we have a function

def lift[T](x: T) -> Covariant[T]: ...

we would transform A & B into Covariant[A & B], not Covariant[A] & Covariant[B]. Now, Covariant[A & B] is a subtype of Covariant[A] & Covariant[B], but they're not equivalent. Is type an exception?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The typing spec explicitly defines type[A | B] as syntactic sugar for type[A] | type[B]. Since the "type argument" of type cannot be an arbitrary type, but must be a nominal class or typevar (otherwise type isn't really meaningful), type[A | B] would not be valid or defined, if it were not explicitly defined as equivalent to type[A] | type[B].

I haven't spent a long time thinking about it, but I think the same thing applies to type[A & B]. It is only valid/meaningful if it is explicitly defined as shorthand for type[A] & type[B].

@sharkdp sharkdp left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I'm very sorry that I only have a high level review comment here, but I spent some time staring at this PR (and its predecessor) today and it feels like we're actually trying to solve a pretty fundamental problem (calling a method on an intersection type), but the implementation looks like a collection of special cases in various random places of the code base. It's certainly possible that all of this is needed (I will take another look tomorrow morning when I'm less tired), but it feels a bit off to me.

Some things that I was thinking about while reading this:

  • Is type[C] the only type that needs separate special cases, or will we need more patches for other types that are also not handled by the original PR?
  • Why are there changes in the generics solver in this PR?
  • Could/should some of this be solved by eagerly simplifying type[A] & type[B] to type[A & B]?


### Classmethod binding skips negative metaclass constraints

A negative metaclass constraint describes the class object's runtime class, not instances of the

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

There are too many meanings of "constraint" already. It would be great if we could keep the "negative element of an intersection type" terminology.

@charliermarsh

Copy link
Copy Markdown
Member Author

Let's just close this then. There are a significant number of bugs and limitations related to intersection types but I don't think it makes sense to prioritize this work right now if it's going to take a bunch of time.

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