Skip to content

Conversation

@sterliakov
Copy link
Collaborator

@sterliakov sterliakov commented Oct 30, 2025

Fixes #20358.

As discovered by @randolf-scholz in #20142, mypy treats value patterns in match statement in a completely wrong way.

From PEP 622:

Literal pattern uses equality with literal on the right hand side, so that in the above example number == 0 and then possibly number == 1, etc will be evaluated.

Existing tests for the feature are invalid: for example,

foo: object

match foo:
    case 1:
        reveal_type(foo)

must reveal object, and test testMatchValuePatternNarrows asserts the opposite. Here's a runtime example:

>>> class A:
...     def __eq__(self,o): return True
...     
>>> match A():
...     case 1:
...         print("eq")
...         
eq

I have updated the existing tests accordingly.

The idea is that value patterns are essentially equivalent to if foo == SomeValue checks, not isinstance checks modelled by conditional_types_wit_intersection.

The original implementation was introduced in #10191.

@github-actions

This comment has been minimized.

@sterliakov
Copy link
Collaborator Author

I'm frankly not sure what part of the core team to ping here - reviewers of the original PR, @JukkaL? @randolf-scholz please also take a look if you have time, IIRC some recent PRs of yours were related to pattern matching?

Primer finds nothing, apparently match isn't that popular in wild...

@sterliakov sterliakov marked this pull request as ready for review October 30, 2025 14:30
@randolf-scholz
Copy link
Contributor

Primer finds nothing, apparently match isn't that popular in wild...

Well, many popular projects still support 3.9, but match-case requires at least 3.10, so that's not surprising.

@randolf-scholz
Copy link
Contributor

randolf-scholz commented Oct 30, 2025

One thing that might be worth testing is if it interacts correctly with unions types and union patterns, e.g.

from typing import Literal

def test1(x: Literal[1,2,3]) -> None:
    match x:
        case 1:
            reveal_type(x)
        case other:
            reveal_type(x)

def test2(x: Literal[1,2,3]) -> None:
    match x:
        case 1:
            reveal_type(x)
        case 2:
            reveal_type(x)
        case 3:
            reveal_type(x)
        case other:
            assert_never(x)

def test3(x: Literal[1,2,3]) -> None:
    match x:
        case 1 | 3:
            reveal_type(x)
        case other:
            reveal_type(x)

and possibly the same with enum arguments / enum patterns.

@sterliakov
Copy link
Collaborator Author

I don't want to add any enum tests here, they won't be representative due to #19594 - I'd prefer to add match equivalents to tests there once this PR is merged. Unions are more relevant, thanks!

@github-actions

This comment has been minimized.

@github-actions

This comment has been minimized.

@github-actions
Copy link
Contributor

github-actions bot commented Dec 3, 2025

According to mypy_primer, this change doesn't affect type check results on a corpus of open source code. ✅

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

Choose a reason for hiding this comment

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

Does this narrow the type variable upper bound? (You can check e.g. by adding an attribute to only one class and accessing it here). If not, I would say this is a regression.

Copy link
Collaborator Author

@sterliakov sterliakov Dec 9, 2025

Choose a reason for hiding this comment

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

It does not, but I won't really call that a regression - we do not support the equivalent equality case either.

[case testEqualsTypeObjectTypeVar]
# flags: --warn-unreachable
from typing import TypeVar
import b

T_Choice = TypeVar("T_Choice", bound=b.One | b.Two)

def switch(choice: type[T_Choice]) -> None:
    if choice == b.One:  # E: Unsupported left operand type for == (some union)
        reveal_type(choice)  # N: Revealed type is "type[T_Choice`-1]"
        reveal_type(choice.x)  # E: "type[T_Choice]" has no attribute "x" \
                               # N: Revealed type is "Union[builtins.int, Any]"

[file b.py]
class One:
    x = 0
class Two: ...

so either it is unsafe or unsupported, but either way does not belong to this PR. I'll open a ticket and try to look into this later.

("unsupported left operand type" is really weird to see here, but it sheds some light on why the match version does not work either)

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

It only worked before by pure coincidence, isinstance logic is fundamentally wrong thing to apply to value patterns.

Copy link
Member

Choose a reason for hiding this comment

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

isinstance logic is fundamentally wrong thing to apply to value patterns

I understand that, but we need to minimize side effects of the fix.

Copy link
Member

Choose a reason for hiding this comment

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

@hauntsaninja IIRC you added this test case. Does this change look OK in context of #19159

Copy link
Contributor

Choose a reason for hiding this comment

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

Isn't the behavior that #19159 wants unsafe to begin with?

In the original example, if one makes class A(b.One): ..., then switch(A) will at runtime catch the assert_never.

Copy link
Member

Choose a reason for hiding this comment

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

Yeah, I vaguely remember someone already mentioned we should allow this only if the classes are final. Anyway, I would wait to see if @hauntsaninja has any objections here.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Thanks @randolf-scholz! It certainly is unsafe, a subclass will not compare equal to a superclass. Could you cross-post this observation to #19159 too, please? Negative narrowing could only work with two @final classes, which is as close to a "corner case we can ignore for now" as possible.

@ilevkivskyi positive narrowing you're asking about would also be unsafe if we pass a Two subclass with a metaclass overriding __eq__:

[case testSomething]
from typing import TypeVar
import b

T_Choice = TypeVar("T_Choice", bound=b.One | b.Two)

def switch(choice: type[T_Choice]) -> None:
    match choice:
        case b.One:
            choice.x  # AttributeError at runtime

class Mcs(type):
    def __eq__(self, other): return True

class BadTwo(b.Two, metaclass=Mcs): ...

switch(BadTwo)


[file b.py]
class One:
    x = 0
class Two: ...

and I don't remember how custom mcs __eq__ is handled - does mypy consider that off-limits?

Copy link
Member

Choose a reason for hiding this comment

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

I think we can assume people don't do this :-) There are other places where user-defined __eq__ would cause a false positive, in those cases we are careful, but we can accept this false negative IMO.

Copy link
Collaborator

@hauntsaninja hauntsaninja Dec 11, 2025

Choose a reason for hiding this comment

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

Yes, I think the change in this PR is good

I don't think losing the accidental positive narrowing here will regress much (but could be good feature to add positive narrowing for this in the future — we can ignore metaclass __eq__).

(and yeah, points on negative narrowing are correct, I think we can just close #19159 )

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

numpy dtype Statement is unreachable with mypy 1.19.0

5 participants