Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Attempt to narrow down type results in new types added into the mix in unexpected way #8238

Closed
danpascu opened this issue Jun 26, 2024 · 6 comments
Labels
addressed in next version Issue is fixed and will appear in next published version bug Something isn't working

Comments

@danpascu
Copy link

With this code:

from typing import reveal_type

type Data = str | bytes | int | float | complex


class Attribute[D: Data]:
    def __init__(self, data_type: type[D], /) -> None:
        reveal_type(data_type)  # type of "data_type" is "type[D@Attribute]"

        if issubclass(data_type, float):
            reveal_type(data_type)  # type of "data_type" is "type[float]*"
        else:
            reveal_type(data_type)  # type of "data_type" is "type[str]* | type[bytes]* | type[int]* | type[complex]*"

        reveal_type(data_type)  # type of "data_type" is "type[float]* | type[str]* | type[bytes]* | type[int]* | type[complex]*"

reveal_type(...) shows the type being narrowed correctly (see comments on each reveal_type line for the pyright messages)

We see the types being narrowed inside the if into float vs the rest and after the if they are recombined to give the original set of types.

Now if I add an assert to narrow it down and eliminate one of the types (complex in this case), look what happens to the inferred types (see the comments):

from typing import reveal_type

type Data = str | bytes | int | float | complex


class Attribute[D: Data]:
    def __init__(self, data_type: type[D], /) -> None:
        reveal_type(data_type)  # type of "data_type" is "type[D@Attribute]"

        assert not issubclass(data_type, complex)

        reveal_type(data_type)  # type of "data_type" is "type[str]* | type[bytes]* | type[int]* | type[float]*"

        if issubclass(data_type, float):
            reveal_type(data_type)  # type of "data_type" is "type[float]*"
        else:
            reveal_type(data_type)  # type of "data_type" is "type[str]* | type[bytes]* | type[bytearray]* | type[memoryview]* | type[int]*"

        reveal_type(data_type)  # type of "data_type" is "type[float]* | type[str]* | type[bytes]* | type[bytearray]* | type[memoryview]* | type[int]*"

Right after the assert, things look fine, with complex being removed from the set of types and we get a list with the remaining ones. This is expected. But inside the else branch of the test below it, suddenly type[bytearray]* and type[memoryview]* are added into the mix and after the if block, they are still present in the recombined set of types, even though they were never mentioned in the original set of types in type Data = ....

This is with command line pyright version 1.1.369

@danpascu danpascu added the bug Something isn't working label Jun 26, 2024
@danpascu
Copy link
Author

danpascu commented Jun 26, 2024

Right after I submitted the issue I found an even simpler example with just 2 asserts. After the 1st things look fine, but after the 2nd bytearray and memoryview are added into the mix:

from typing import reveal_type

type Data = str | bytes | int | float | complex


class Attribute[D: Data]:
    def __init__(self, data_type: type[D], /) -> None:
        reveal_type(data_type)  # type of "data_type" is "type[D@Attribute]"

        assert not issubclass(data_type, complex)

        reveal_type(data_type)  # type of "data_type" is "type[str]* | type[bytes]* | type[int]* | type[float]*"

        assert not issubclass(data_type, float)

        reveal_type(data_type)  # type of "data_type" is "type[str]* | type[bytes]* | type[bytearray]* | type[memoryview]* | type[int]*"

Even an attempt to explicitly remove bytearray and memoryview from being possible candidates fails:

from typing import reveal_type

type Data = str | bytes | int | float | complex


class Attribute[D: Data]:
    def __init__(self, data_type: type[D], /) -> None:
        if issubclass(data_type, bytearray | memoryview):
            raise TypeError

        reveal_type(data_type)  # type of "data_type" is "type[str]* | type[bytes]* | type[int]* | type[float]* | type[complex]*"

        assert not issubclass(data_type, complex)

        reveal_type(data_type)  # type of "data_type" is "type[str]* | type[bytes]* | type[bytearray]* | type[memoryview]* | type[int]* | type[float]*"

        assert not issubclass(data_type, float)

        reveal_type(data_type)  # type of "data_type" is "type[str]* | type[bytes]* | type[bytearray]* | type[memoryview]* | type[int]*"

@erictraut
Copy link
Collaborator

Pyright is working as intended here, so this isn't a bug.

Traditionally, when bytes is used in a type annotation, it has meant bytes | bytearray | memoryview. This is known as a "promotion type". The complex and float types are similar. The difference is that complex and bytes are explicitly called out as promotion types in the typing spec, whereas bytes was just a convention added by mypy and relied upon by typeshed and other libraries over time.

There is a plan to deprecate the bytes promotion type behavior. Pyright offers the configuration option disableBytesTypePromotions, which currently defaults to false. I plan to change its default to true in the near future. If you would like that behavior now, you can configure it to true in your project.

@erictraut erictraut closed this as not planned Won't fix, can't repro, duplicate, stale Jun 26, 2024
@erictraut erictraut added the as designed Not a bug, working as intended label Jun 26, 2024
@danpascu
Copy link
Author

danpascu commented Jun 26, 2024

I know that bytes stands in for bytearray and memoryview, but my question is why is the behavior not consistent? Why does it introduce them only after the 2nd type narrowing.

And more importantly why does it ignore my explicit exclusion of bytearray and memoryview and still reintroduces them later. If I move if issubclass(data_type, bytearray | memory): raise TypeError right before the last reveal_type then suddenly it accepts my narrowing, but if I keep it at the top it simply ignores it.
There is nothing between those lines that mentions bytes or any bytes-like type so I see no reason why the type narrowing is ignored or why it takes 2 narrowing statements before it decides to introduce bytearray and memoryview.

@erictraut
Copy link
Collaborator

Ah, I see what you mean. I missed that in your original post. Reopening for additional investigation.

@erictraut erictraut reopened this Jun 26, 2024
@erictraut erictraut removed the as designed Not a bug, working as intended label Jun 26, 2024
@danpascu
Copy link
Author

Yeah, my original post might be a bit confusing. Check my 2nd post, when I realized I can replicate this with an easier setup that also makes it clearer to see what happens.

erictraut added a commit that referenced this issue Jun 26, 2024
… type narrowing when using a type variable with an upper bound that includes a promotion type. This addresses #8238.
erictraut added a commit that referenced this issue Jun 26, 2024
… type narrowing when using a type variable with an upper bound that includes a promotion type. This addresses #8238. (#8243)
@erictraut erictraut added the addressed in next version Issue is fixed and will appear in next published version label Jun 26, 2024
@erictraut
Copy link
Collaborator

This is addressed in pyright 1.1.370

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
addressed in next version Issue is fixed and will appear in next published version bug Something isn't working
Projects
None yet
Development

No branches or pull requests

2 participants