Skip to content

Conversation

@hauntsaninja
Copy link
Collaborator

@hauntsaninja hauntsaninja commented Dec 30, 2025

Mypy does not narrow as much as it could, which results in false positives.

We would also like to narrow based on containment. The PR for that was previously reverted due to inconsistencies between narrowing via equality and via containment. This fixes the inconsistency on the equality side and paves the road for adding narrowing via containment. That is, we lay groundwork for fixing #17864 and fixing #17841

Fixes #18524
Fixes #20041
Fixes #17162
Fixes #16830
Fixes #13704
Fixes #7642
Fixes #3964

@github-actions

This comment has been minimized.

@hauntsaninja hauntsaninja force-pushed the narrowcontain branch 2 times, most recently from 6e89ffd to 9e351d1 Compare December 30, 2025 10:38
@github-actions

This comment has been minimized.

@github-actions

This comment has been minimized.

@github-actions

This comment has been minimized.

@github-actions

This comment has been minimized.

@github-actions

This comment has been minimized.

@github-actions

This comment has been minimized.

@github-actions

This comment has been minimized.

@github-actions

This comment has been minimized.

@hauntsaninja hauntsaninja marked this pull request as ready for review December 31, 2025 05:01
@hauntsaninja
Copy link
Collaborator Author

hauntsaninja commented Dec 31, 2025

Primer is looking pretty good, with a few things I think maybe worth exploring for future PR (e.g. TypeType handling, the schemathesis / zulip false positives).

My laptop is pretty noisy, this was maybe 1.5% hit on self check. I will explore optimisations once this is merged (edit: I think on my latest dev branch that contains the whole series of changes it is down to within noise)

Changes I have ready on top of this PR:

  • A pure refactor, that makes some of this code easier to follow
  • Narrow types based on collection containment
  • Refactor and improve narrowing for type(x) == t
  • Avoid narrowing TypeType (this fixes 1-2 issues in the primer for this PR, but I think is worth looking at separately)
  • Improve narrowing logic for Enum int and str subclasses
  • Treat NotImplemented as a singleton

Things I want to explore after that are: literal coercion, checking unreachable code, narrow for __class__, some specific things like 1 2

hauntsaninja added a commit to hauntsaninja/mypy that referenced this pull request Jan 1, 2026
This relates to changes made in python#20492
@hauntsaninja hauntsaninja requested a review from JukkaL January 2, 2026 23:55
hauntsaninja added a commit to hauntsaninja/mypy that referenced this pull request Jan 3, 2026
This relates to changes made in python#20492
@A5rocks A5rocks self-requested a review January 7, 2026 05:11
Comment on lines 6547 to +6550
and not is_literal_none(expr)
and not is_literal_not_implemented(expr)
and not is_false_literal(expr)
and not is_true_literal(expr)
Copy link
Collaborator

Choose a reason for hiding this comment

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

Shouldn't these be LITERAL_YES? I guess maybe they aren't now, but that seems like the right distinction.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Yeah, good point. Good thing to explore in a follow up PR

operand_types,
expr_indices,
narrowable_operand_index_to_hash.keys(),
operator, operands, operand_types, expr_indices, narrowable_indices=narrowable_indices
Copy link
Collaborator

Choose a reason for hiding this comment

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

Any deeper reason behind why specifically this arg and none of the others is consistently passed via kwarg?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

No particularly deep reason

narrowable_operand_index_to_hash as a dict is much more complicated than narrowable_indices as a set, so I wanted to only have narrowable_operand_index_to_hash in the places you actually need a dict, so I did a little bit of renaming

) -> tuple[TypeMap, TypeMap]:
"""Calculate type maps for '==', '!=', 'is' or 'is not' expression."""
# If we haven't been able to narrow types yet, we might be dealing with a
# explicit type(x) == some_type check
Copy link
Collaborator

Choose a reason for hiding this comment

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

Isn't this comment in the wrong place/wrong now?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I guess so. One of the commits I have on top of this PR gets rid of this function entirely, so won't touch for now

# custom __eq__. We should see if we can get rid of it.
return self.refine_away_none_in_comparison(
operands, operand_types, expr_indices, narrowable_indices
)
Copy link
Collaborator

Choose a reason for hiding this comment

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

Is the reason this is still here because narrow_identity_equality_comparison doesn't happen for negative cases, even though it should? So something like #18574 would help?

Copy link
Collaborator Author

@hauntsaninja hauntsaninja Jan 10, 2026

Choose a reason for hiding this comment

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

We now hit this call much less and I want to see if we can get rid of it entirely. I need to look at primer for that

Yes, something with the effect of #18574 could still be useful! I think once I land my series of changes, the impl for it could look fairly clean...

Copy link
Collaborator Author

@hauntsaninja hauntsaninja Jan 10, 2026

Choose a reason for hiding this comment

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

Okay, I wrote a patch for this in my commit stack. I think it looks pretty promising (e.g. we have the fully ideal behaviour in testNarrowingEqualityDisabledForCustomEquality) and I think does let us get rid of refine_away_none_in_comparison, thanks for mentioning!

for i in chain_indices:
if i not in narrowable_operand_indices:
continue
for j, target in value_targets:
Copy link
Collaborator

Choose a reason for hiding this comment

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

I guess one downside of this reworking is that this is O(n^2) now?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Yes, if you have a very long chained comparison (where a lot of the terms are narrowable and valid targets). Chained comparisons are pretty rare though.

Similarly, 'coerce_only_in_literal_context' controls whether we should try coercing
expressions in the chain to a Literal type. Performing this coercion is sometimes
too aggressive of a narrowing, depending on context.
"""
Copy link
Collaborator

Choose a reason for hiding this comment

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

I'm a bit overwhelmed by the diff view for this function. What's the actual logic change?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Best to view this part as a rewrite, not a diff

Main logic change is more principled handling of "values" vs "types"
Previously if not is_valid_target this function didn't really do anything. Now we narrow, but only in positive case

(This also means we don't have to call refine_away_none_in_comparison anymore, which improves soundness, see the test cases with random_object)

I do have a follow up commit that improves readability here, but it does that by doing more refactoring. I wanted to keep that separate PR since it doesn't change behaviour, but would have made this diff harder to read

Copy link
Collaborator Author

@hauntsaninja hauntsaninja left a comment

Choose a reason for hiding this comment

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

Thanks for taking a look, I responded to your comments

Comment on lines 6547 to +6550
and not is_literal_none(expr)
and not is_literal_not_implemented(expr)
and not is_false_literal(expr)
and not is_true_literal(expr)
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Yeah, good point. Good thing to explore in a follow up PR

) -> tuple[TypeMap, TypeMap]:
"""Calculate type maps for '==', '!=', 'is' or 'is not' expression."""
# If we haven't been able to narrow types yet, we might be dealing with a
# explicit type(x) == some_type check
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I guess so. One of the commits I have on top of this PR gets rid of this function entirely, so won't touch for now

operand_types,
expr_indices,
narrowable_operand_index_to_hash.keys(),
operator, operands, operand_types, expr_indices, narrowable_indices=narrowable_indices
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

No particularly deep reason

narrowable_operand_index_to_hash as a dict is much more complicated than narrowable_indices as a set, so I wanted to only have narrowable_operand_index_to_hash in the places you actually need a dict, so I did a little bit of renaming

# custom __eq__. We should see if we can get rid of it.
return self.refine_away_none_in_comparison(
operands, operand_types, expr_indices, narrowable_indices
)
Copy link
Collaborator Author

@hauntsaninja hauntsaninja Jan 10, 2026

Choose a reason for hiding this comment

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

We now hit this call much less and I want to see if we can get rid of it entirely. I need to look at primer for that

Yes, something with the effect of #18574 could still be useful! I think once I land my series of changes, the impl for it could look fairly clean...

Similarly, 'coerce_only_in_literal_context' controls whether we should try coercing
expressions in the chain to a Literal type. Performing this coercion is sometimes
too aggressive of a narrowing, depending on context.
"""
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Best to view this part as a rewrite, not a diff

Main logic change is more principled handling of "values" vs "types"
Previously if not is_valid_target this function didn't really do anything. Now we narrow, but only in positive case

(This also means we don't have to call refine_away_none_in_comparison anymore, which improves soundness, see the test cases with random_object)

I do have a follow up commit that improves readability here, but it does that by doing more refactoring. I wanted to keep that separate PR since it doesn't change behaviour, but would have made this diff harder to read

for i in chain_indices:
if i not in narrowable_operand_indices:
continue
for j, target in value_targets:
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Yes, if you have a very long chained comparison (where a lot of the terms are narrowable and valid targets). Chained comparisons are pretty rare though.

hauntsaninja added a commit to hauntsaninja/mypy that referenced this pull request Jan 12, 2026
This relates to changes made in python#20492
Copy link
Collaborator

@JukkaL JukkaL left a comment

Choose a reason for hiding this comment

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

Thanks, I'm glad to see so many bugs fixed! This is something I was planning to work by myself at some point but I got distracted. Left a few minor comments, looks great overall.

match choice:
case b.One:
reveal_type(choice) # N: Revealed type is "type[T_Choice`-1]"
reveal_type(choice) # N: Revealed type is "def () -> b.One"
Copy link
Collaborator

Choose a reason for hiding this comment

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

Can choice be used as a type object when narrowed? Test this.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Will add! For context, this test case has flipped around a little recently: https://github.com/python/mypy/pull/20146/files#r2602384622

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Opened #20598

)
if if_map == {} and else_map == {} and node is not None:
if_map, else_map = self.find_type_equals_check(node, expr_indices)
if node is not None:
Copy link
Collaborator

Choose a reason for hiding this comment

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

node doesn't have an optional type, so is the condition always true?

@hauntsaninja hauntsaninja merged commit ad30be8 into python:master Jan 16, 2026
23 checks passed
@hauntsaninja hauntsaninja deleted the narrowcontain branch January 16, 2026 20:47
hauntsaninja added a commit that referenced this pull request Jan 16, 2026
This is a follow up to #20492 that is
pure refactoring. I separated it out to make that change easier to
review. We inline `narrow_identity_equality_comparison`, improve
comments, etc.

There is some further refactoring later in my commit stack too.
hauntsaninja added a commit that referenced this pull request Jan 16, 2026
hauntsaninja added a commit to hauntsaninja/mypy that referenced this pull request Jan 17, 2026
This relates to changes made in python#20492
@cdce8p
Copy link
Collaborator

cdce8p commented Jan 17, 2026

Noticed a minor regression in Home Assistant with --warn-unreachable. Not sure if it's tracked already.

# mypy: warn-unreachable
from enum import IntFlag

class WeatherFeature(IntFlag):
    FORECAST_DAILY = 1
    FORECAST_HOURLY = 2
    FORECAST_TWICE_DAILY = 4

def func(supported_features: int) -> None:
    reveal_type(supported_features)
    if (supported_features & WeatherFeature.FORECAST_DAILY) == 0:
        print("Hello World")  # <-- This should work fine
note: Revealed type is "builtins.int"
error: Statement is unreachable  [unreachable]

@hauntsaninja
Copy link
Collaborator Author

Thanks, I have a follow up commit that will fix this case

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

4 participants