[ty] Avoid expanding same-enum comparisons#26270
Conversation
Typing conformance resultsNo changes detected ✅Current numbersThe 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.10%. The number of fully passing files held steady at 95/134. |
Memory usage reportSummary
Significant changesClick to expand detailed breakdownflake8
sphinx
prefect
trio
|
|
| Lint rule | Added | Removed | Changed |
|---|---|---|---|
invalid-key |
0 | 7 | 0 |
| Total | 0 | 7 | 0 |
Flaky changes detected. This PR summary excludes flaky changes; see the HTML report for details.
Raw diff:
freqtrade (https://github.com/freqtrade/freqtrade)
- freqtrade/rpc/telegram.py:579:59 error[invalid-key] Unknown key "status" for TypedDict `RPCAnalyzedDFMsg` (subscripted object has type `RPCStatusMsg | RPCStrategyMsg | RPCProtectionMsg | ... omitted 5 union elements`)
- freqtrade/rpc/telegram.py:579:59 error[invalid-key] Unknown key "status" for TypedDict `RPCEntryMsg` (subscripted object has type `RPCStatusMsg | RPCStrategyMsg | RPCProtectionMsg | ... omitted 5 union elements`)
- freqtrade/rpc/telegram.py:579:59 error[invalid-key] Unknown key "status" for TypedDict `RPCExitMsg` (subscripted object has type `RPCStatusMsg | RPCStrategyMsg | RPCProtectionMsg | ... omitted 5 union elements`)
- freqtrade/rpc/telegram.py:579:59 error[invalid-key] Unknown key "status" for TypedDict `RPCNewCandleMsg` (subscripted object has type `RPCStatusMsg | RPCStrategyMsg | RPCProtectionMsg | ... omitted 5 union elements`)
- freqtrade/rpc/telegram.py:579:59 error[invalid-key] Unknown key "status" for TypedDict `RPCProtectionMsg` (subscripted object has type `RPCStatusMsg | RPCStrategyMsg | RPCProtectionMsg | ... omitted 5 union elements`)
- freqtrade/rpc/telegram.py:579:59 error[invalid-key] Unknown key "status" for TypedDict `RPCStrategyMsg` (subscripted object has type `RPCStatusMsg | RPCStrategyMsg | RPCProtectionMsg | ... omitted 5 union elements`)
- freqtrade/rpc/telegram.py:579:59 error[invalid-key] Unknown key "status" for TypedDict `RPCWhitelistMsg` (subscripted object has type `RPCStatusMsg | RPCStrategyMsg | RPCProtectionMsg | ... omitted 5 union elements`)732534f to
534e01f
Compare
55d526f to
5af5b6e
Compare
7e97231 to
c031ea2
Compare
5af5b6e to
d40b4b1
Compare
## Summary The equality rewrite in #25788 centralized runtime `==` and `!=` reasoning for narrowing and match reachability, but ordinary comparison inference continued to use separate literal and dunder logic. As a result, two disjoint enum domains could narrow correctly while the comparison expression itself remained `bool`, keeping an impossible branch reachable. This routes definite equality and inequality truthiness through the shared comparison evaluator before falling back to the existing inference path. Ambiguous comparisons still use normal dunder inference, preserving custom non-boolean return types and the independence of `__eq__` and `__ne__`. ```python from enum import Enum from typing import Literal class Choice(str, Enum): FIRST = "first" SECOND = "second" THIRD = "third" FOURTH = "fourth" def compare( left: Literal[Choice.FIRST, Choice.SECOND], right: Literal[Choice.THIRD, Choice.FOURTH], ): reveal_type(left == right) # Literal[False] reveal_type(left != right) # Literal[True] ``` This also resolves existing tuple comparison TODOs whose mismatched literal elements are already known to compare unequal. With the behavior provided here, #26270 can remain responsible for avoiding same-enum domain expansion without changing comparison results.
c031ea2 to
9ca4e30
Compare
2a06e75 to
3faf443
Compare
9ca4e30 to
b619ccc
Compare
2ef0050 to
5b77421
Compare
b619ccc to
b7c3250
Compare
## Summary
We currently treat the members declared on every enum as its complete
runtime domain. That assumption does not hold for `Flag` and `IntFlag`,
which also have zero and unnamed combinations, or for enums whose custom
`_missing_` method or metaclass can create additional members. As a
result, we can incorrectly treat a one-member enum as a singleton, widen
all declared members to the nominal enum, erase a non-empty complement,
or consider a `match` over the declared members exhaustive.
This adds a `members_are_exhaustive` property to `EnumClassLiteral` and
uses it wherever we rely on an enum being a finite set: complement
construction, union simplification, singleton detection,
finite-alternative narrowing, and match exhaustiveness. Open enums
retain the nominal remainder after known members are excluded.
Explicit membership tests remain precise when the enum uses identity
comparison. For example, a metaclass may add `INJECTED`, but testing
against `ONLY` still establishes that exact member on the positive
branch and excludes only that member on the negative branch:
```python
class OpenEnum(Enum, metaclass=InjectingEnumMeta):
ONLY = 1
def check(value: OpenEnum):
if value in (OpenEnum.ONLY,):
reveal_type(value) # Literal[OpenEnum.ONLY]
else:
reveal_type(value) # OpenEnum & ~Literal[OpenEnum.ONLY]
```
This is the semantic foundation split out of #26270; that PR adds the
compact same-enum comparison path on top.
28b3415 to
3f9b8f2
Compare
jelle-openai
left a comment
There was a problem hiding this comment.
Do we correctly model comparison between distinct enum types?
r>>> import enum
>>> class X(enum.StrEnum):
... a = "a"
...
>>> class Y(enum.StrEnum):
... a = "a"
...
>>> X.a == Y.a
True|
|
||
| def compare_after_truthiness_check(left: Choice, right: Choice): | ||
| if right and left != right: | ||
| reveal_type(right) # revealed: Choice & ~AlwaysFalsy |
There was a problem hiding this comment.
Maybe unrelated, but ideally we should simplify out the AlwaysFalsy here. Choice has no instances that are falsy and ty should be able to figure that out.
| right: Literal[Choice.SECOND, Choice.THIRD], | ||
| ): | ||
| if left == right: | ||
| reveal_type(left) # revealed: Literal[Choice.SECOND] |
There was a problem hiding this comment.
cool that ty can do this
| } | ||
|
|
||
| /// Return the constraint established by membership in an exact set of open-enum members. | ||
| /// Compare two compact domains from the same enum without expanding either operand. |
There was a problem hiding this comment.
What is "compact" here?
There was a problem hiding this comment.
(Tried to simplify.)
|
No, behavior is unchanged vs. main in that case -- it just reveals |
3f9b8f2 to
e88a854
Compare
|
I might look at that separately. I agree we should get it right. |
|
yeah, my original hope when I began the big equality-narrowing refactor was that we would be able to reuse the equality evaluator in type inference as well as narrowing. That should theoretically be possible, and it would be great if we could get consistent results between the two areas |
|
Maybe I should explore that first though before we merge this. |
|
@AlexWaygood -- do you mean like #26290? (I merged this by accident then reverted.) |
|
I'm fine with comparison across different enums returning bool; it seems pretty pathological for user code to rely on cross-enum comparisons. Just want to make sure we don't incorrectly infer "it's a totally different enum, so eq must return False". |
yes! |
|
Yeah we still get that case right (from that perspective) -- we fall back to |
|
Moving to draft while I think about the other pieces here (reusing shared equality, cross-enum comparisons). |
oconnor663
left a comment
There was a problem hiding this comment.
The implementation in this PR makes sense to me, modulo @AlexWaygood's comment about reuse.
| if left is NeverEqual.FIRST: | ||
| return | ||
| reveal_type(left == right) # revealed: Literal[False] |
There was a problem hiding this comment.
Could expand this test case:
| if left is NeverEqual.FIRST: | |
| return | |
| reveal_type(left == right) # revealed: Literal[False] | |
| if left is NeverEqual.FIRST and right is NeverEqual.FIRST: | |
| reveal_type(left == right) # revealed: Literal[False] | |
| class AlwaysEqual(Enum): | |
| FIRST = 1 | |
| SECOND = 2 | |
| THIRD = 3 | |
| def __eq__(self, other: object) -> Literal[True]: | |
| return True | |
| def _(left: AlwaysEqual, right: AlwaysEqual): | |
| reveal_type(left == right) # revealed: Literal[True] | |
| if left is AlwaysEqual.FIRST and right is AlwaysEqual.SECOND: | |
| reveal_type(left == right) # revealed: Literal[True] | |
| class EqualityUnknown(Enum): | |
| FIRST = 1 | |
| SECOND = 2 | |
| THIRD = 3 | |
| def __eq__(self, other: object): ... | |
| def _(left: EqualityUnknown, right: EqualityUnknown): | |
| reveal_type(left == right) # revealed: bool | |
| if left is EqualityUnknown.FIRST and right is EqualityUnknown.FIRST: | |
| reveal_type(left == right) # revealed: bool | |
| if left is EqualityUnknown.FIRST and right is EqualityUnknown.SECOND: | |
| reveal_type(left == right) # revealed: bool |
|
Replacing this with #26340 which handles both same-class and across-class operations. |
Summary
When comparing two narrowed values of the same enum, we materialize the remaining members and compare their cross-product, which is extremely slow for a large enum.
This PR adds a compact representation for operands from the same enum: all members, one member, an included set, or all members except a known set. We compare those sets directly to determine overlap, truthiness, and safe branch constraints without expanding member pairs.
This also makes comparison expressions more precise when the enum's comparison behavior and member values are known. Previously, comparisons between ordinary
Enummembers could produceLiteral[True]orLiteral[False], whileStrEnum,IntEnum, and other scalar-mixin enums generally fell back to a dunder method whose annotated return type was onlybool. We now use the same modeled runtime values and comparison semantics for both narrowing and expression inference:Closes astral-sh/ty#3830.