Skip to content

[ty] Prefer reflected operators by runtime class#26434

Open
charliermarsh wants to merge 1 commit into
mainfrom
charlie/reflected-operator-runtime-classes
Open

[ty] Prefer reflected operators by runtime class#26434
charliermarsh wants to merge 1 commit into
mainfrom
charlie/reflected-operator-runtime-classes

Conversation

@charliermarsh

@charliermarsh charliermarsh commented Jun 27, 2026

Copy link
Copy Markdown
Member

Summary

Python gives the right operand's reflected method priority when its runtime class is a strict subclass of the left operand's runtime class and it provides a different implementation.

Prior to #15161, this dispatch path only handled class-instance types, so checking whether the right instance type was a subtype of the left effectively modeled the runtime-class relationship. #15161 generalized the path to literals and other value types but retained the same subtype predicate. That is not equivalent for singleton types: an enum literal is not a subtype of a specific integer literal even though its runtime class can be a strict subclass of int.

This PR compares the operands' nominal runtime classes when deciding reflected-method precedence. This lets a common IntFlag accumulator pattern retain the enum type produced at runtime:

from enum import IntFlag, auto

class Permission(IntFlag):
    READ = auto()
    WRITE = auto()

def permissions_for(editable: bool) -> Permission:
    permissions = 0
    permissions |= Permission.READ

    if editable:
        permissions |= Permission.WRITE

    return permissions

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

astral-sh-bot Bot commented Jun 27, 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 94.47%. The percentage of expected errors that received a diagnostic held steady at 89.19%. The number of fully passing files held steady at 95/134.

@astral-sh-bot

astral-sh-bot Bot commented Jun 27, 2026

Copy link
Copy Markdown

Memory usage report

Summary

Project Old New Diff Outcome
sphinx 167.01MB 167.03MB +0.02% (26.05kB)
flake8 29.02MB 29.02MB +0.00% (240.00B)
trio 70.45MB 70.44MB -0.01% (10.60kB) ⬇️
prefect 449.06MB 449.05MB -0.00% (17.13kB) ⬇️

Significant changes

Click to expand detailed breakdown

sphinx

Name Old New Diff Outcome
StaticClassLiteral<'db>::try_mro_ 1.80MB 1.81MB +0.99% (18.19kB)
Specialization 1.42MB 1.43MB +0.42% (6.12kB)
StaticClassLiteral<'db>::try_mro_::interned_arguments 611.86kB 617.48kB +0.92% (5.62kB)
GenericAlias 624.02kB 627.96kB +0.63% (3.94kB)
Type<'db>::class_member_with_policy_ 3.60MB 3.60MB -0.05% (1.80kB)
member_lookup_with_policy_inner 4.29MB 4.29MB -0.04% (1.62kB)
member_lookup_with_policy_inner::interned_arguments 2.71MB 2.71MB -0.05% (1.52kB)
Type<'db>::class_member_with_policy_::interned_arguments 2.36MB 2.36MB -0.05% (1.32kB)
infer_expression_types_impl 14.89MB 14.89MB -0.00% (664.00B)
try_call_bin_op_return_type_impl 78.66kB 79.13kB +0.60% (480.00B)
infer_definition_types 13.69MB 13.69MB -0.00% (432.00B)
place_by_id 1.33MB 1.33MB -0.03% (352.00B)
place_by_id::interned_arguments 1.03MB 1.03MB -0.03% (288.00B)
all_narrowing_constraints_for_expression 1.94MB 1.94MB -0.01% (160.00B)
infer_statement_types_impl 487.89kB 487.80kB -0.02% (88.00B)
... 4 more

flake8

Name Old New Diff Outcome
StaticClassLiteral<'db>::try_mro_ 218.62kB 219.73kB +0.51% (1.11kB)
Specialization 178.42kB 178.86kB +0.25% (448.00B)
Type<'db>::class_member_with_policy_ 236.86kB 236.45kB -0.17% (424.00B)
StaticClassLiteral<'db>::try_mro_::interned_arguments 74.81kB 75.16kB +0.47% (360.00B)
member_lookup_with_policy_inner::interned_arguments 207.07kB 206.72kB -0.17% (360.00B)
member_lookup_with_policy_inner 293.12kB 292.78kB -0.12% (352.00B)
Type<'db>::class_member_with_policy_::interned_arguments 170.42kB 170.12kB -0.18% (312.00B)
GenericAlias 77.55kB 77.84kB +0.36% (288.00B)
infer_definition_types 1002.05kB 1001.91kB -0.01% (152.00B)
OverloadLiteral 120.83kB 120.72kB -0.09% (112.00B)
place_by_id 135.66kB 135.58kB -0.06% (88.00B)
FunctionType 228.89kB 228.81kB -0.03% (80.00B)
place_by_id::interned_arguments 105.82kB 105.75kB -0.07% (72.00B)
try_call_bin_op_return_type_impl 2.49kB 2.45kB -1.57% (40.00B)

trio

Name Old New Diff Outcome
member_lookup_with_policy_inner 1.03MB 1.02MB -0.25% (2.66kB) ⬇️
Type<'db>::class_member_with_policy_ 889.80kB 887.43kB -0.27% (2.38kB) ⬇️
member_lookup_with_policy_inner::interned_arguments 766.76kB 764.77kB -0.26% (1.99kB) ⬇️
Type<'db>::class_member_with_policy_::interned_arguments 627.35kB 625.62kB -0.28% (1.73kB) ⬇️
StaticClassLiteral<'db>::try_mro_ 532.94kB 531.83kB -0.21% (1.11kB) ⬇️
StaticClassLiteral<'db>::try_mro_::interned_arguments 182.46kB 182.11kB -0.19% (360.00B) ⬇️
place_by_id 545.16kB 544.81kB -0.06% (352.00B) ⬇️
UnionType 144.30kB 144.64kB +0.24% (352.00B) ⬇️
Specialization 464.94kB 464.61kB -0.07% (336.00B) ⬇️
place_by_id::interned_arguments 413.37kB 413.09kB -0.07% (288.00B) ⬇️
GenericAlias 192.23kB 191.95kB -0.15% (288.00B) ⬇️
Type<'db>::apply_specialization_inner_::interned_arguments 510.08kB 510.23kB +0.03% (160.00B) ⬇️
Type<'db>::apply_specialization_inner_ 351.43kB 351.52kB +0.03% (96.00B) ⬇️
when_constraint_set_assignable_to_owned_impl::interned_arguments 75.62kB 75.71kB +0.11% (88.00B) ⬇️
is_possibly_constraint_set_assignable::interned_arguments 60.59kB 60.67kB +0.14% (88.00B) ⬇️
... 11 more

prefect

Name Old New Diff Outcome
try_call_bin_op_return_type_impl 103.59kB 96.72kB -6.63% (6.87kB) ⬇️
Type<'db>::class_member_with_policy_ 7.90MB 7.90MB -0.03% (2.36kB) ⬇️
member_lookup_with_policy_inner 10.22MB 10.22MB -0.02% (2.35kB) ⬇️
member_lookup_with_policy_inner::interned_arguments 6.74MB 6.74MB -0.03% (1.88kB) ⬇️
Type<'db>::class_member_with_policy_::interned_arguments 5.73MB 5.73MB -0.03% (1.62kB) ⬇️
infer_definition_types 49.99MB 49.99MB -0.00% (608.00B) ⬇️
OverloadLiteral 3.93MB 3.93MB -0.01% (448.00B) ⬇️
place_by_id 4.78MB 4.78MB -0.01% (352.00B) ⬇️
FunctionType 4.60MB 4.60MB -0.01% (320.00B) ⬇️
place_by_id::interned_arguments 3.68MB 3.68MB -0.01% (288.00B) ⬇️
StringLiteralType 5.87MB 5.87MB -0.00% (80.00B) ⬇️
Type<'db>::apply_specialization_inner_ 2.04MB 2.04MB -0.00% (8.00B) ⬇️

@astral-sh-bot

astral-sh-bot Bot commented Jun 27, 2026

Copy link
Copy Markdown

ecosystem-analyzer results

No diagnostic changes detected ✅

Full report with detailed diff (timing results)

@charliermarsh charliermarsh force-pushed the charlie/reflected-operator-runtime-classes branch from 500bf24 to 8a7f8fa Compare June 27, 2026 19:13
@charliermarsh charliermarsh force-pushed the charlie/reflected-operator-runtime-classes branch 2 times, most recently from 4feb366 to 5def2ee Compare June 27, 2026 23:35
@charliermarsh charliermarsh marked this pull request as ready for review June 27, 2026 23:46
@charliermarsh charliermarsh requested a review from a team as a code owner June 27, 2026 23:46
@charliermarsh charliermarsh added the bug Something isn't working label Jun 27, 2026
@astral-sh-bot astral-sh-bot Bot requested a review from carljm June 27, 2026 23:46
@charliermarsh charliermarsh requested review from sharkdp and removed request for carljm June 30, 2026 13:50

@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.

Thank you, this seems like an improvement.

All of this (including the pre-existing behavior) seems a bit unsound, though? 😄

In a case like this:

from typing import Literal

class Base:
    def __add__(self, other: object) -> Literal["base"]:
        return "base"

class Child(Base):
    def __radd__(self, other: object) -> Literal["child"]:
        return "child"

def f(base: Base, child: Child):
    reveal_type(base + child)

we reveal Literal["child"] because Child is a strict subtype of Base. But the function signature doesn't prevent me from calling f(Child(), Child()), which gives me "base" at runtime.

So instead of switching over to the __radd__ return type completely, we should probably just union it in?

let right_is_strict_subclass = left_ty != right_ty
&& match (left_ty.nominal_class(db), right_ty.nominal_class(db)) {
(Some(left_class), Some(right_class))
if !left_ty.is_type_var() && !right_ty.is_type_var() =>

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 is a bit unfortunate. I've seen similar checks elsewhere (in current PRs of mine, but maybe also pre-existing in the codebase). It seems like most callers of nominal_class DON'T want to resolve typevars to the nominal class of their upper bound? It would be an interesting follow up to see if we should change the implementation of that method and rather check for typevars explicitly in the places where we DO want that behavior.

In my PRs, I also needed to exclude NewType. It looks like we might also need to do that here?

Comment on lines +248 to +249
The upper bound of a TypeVar is not necessarily its runtime class. The bound therefore cannot be
used to decide whether the right-hand operand's reflected method takes precedence:

@sharkdp sharkdp Jun 30, 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.

This test seems correct, but there is also the opposite case, I guess? When the right-hand operand type is a typevar with an upper bound of Child, then it seems like it is safe to assume that __radd__ can take precedence, since everything that the typevar could specialize to would also be a strict subclass.

@charliermarsh

Copy link
Copy Markdown
Member Author

Wow good call. I'm trying this in #26474.

@charliermarsh charliermarsh force-pushed the charlie/reflected-operator-runtime-classes branch from 5def2ee to 6764cb1 Compare June 30, 2026 15:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working ty Multi-file analysis & type inference

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants