python / mypy

Optional static typing for Python
https://www.mypy-lang.org/
Other
18.2k stars 2.78k forks source link

Invalid typing assumption over variable with list(StrEnum) on python 3.11 #14688

Open Nnonexistent opened 1 year ago

Nnonexistent commented 1 year ago

Bug Report

On python 3.11 using StrEnum, if converting given enum to a list and assigning to a variable, mypy will wrongly assumes that given variable will be list[str], not list[StrEnum].

To Reproduce

from enum import StrEnum

class Choices(StrEnum):
    LOREM = "lorem"
    IPSUM = "ipsum"

# This is ok
def ok_func() -> list[Choices]:
    return list(Choices)

# However this produces an error
def error_func() -> list[Choices]:
    var = list(Choices)
    return var

https://mypy-play.net/?mypy=latest&python=3.11&gist=e73a15c8902092236567da4a4567f372

Expected Behavior

Mypy should assume list[Choices] type for var.

Actual Behavior

Incompatible return value type (got "List[str]", expected "List[Choices]") [return-value]

Your Environment

Apakottur commented 9 months ago

In case it helps, I've just encountered the same issue in a slightly different situation:

from enum import StrEnum

class Choices(StrEnum):
    A = "a"

def my_func(obj: Choices) -> None:
    assert isinstance(obj, Choices)

for element in list(Choices):
    my_func(element)

Which gives:

error: Argument 1 to "my_func" has incompatible type "str"; expected "Choices"  [arg-type]

In my case I'm not assigning list(Choices) to anything, but the elements are inferred as str and not Choices.

kikones34 commented 7 months ago

Are there any plans to work on this? We've had to silence this error many times in our codebase :( I can confirm it still happens on Python 3.12 with Mypy 1.8.0.

jhenly commented 7 months ago

I was fiddling around with this issue and I found a, somewhat reasonable, workaround where you don't have to cast or silence the error (@kikones34).

from typing import overload, Self
from enum import StrEnum as _StrEnum

class StrEnum(_StrEnum):

    # mimic str.__new__'s overloads
    @overload
    def __new__(cls, object: object = ...) -> Self: ...
    @overload
    def __new__(cls, object: object, encoding: str = ..., errors: str = ...) -> Self: ...

    def __new__(cls, *values):
        # when we import enum, _StrEnum.__new__ gets moved to _new_member_ when
        # the "final" _StrEnum class is created via EnumType
        return _StrEnum._new_member_(cls, *values)

class Choices(StrEnum):
    LOREM = "lorem"
    IPSUM = "ipsum"

# this is still ok
def ok_func() -> list[Choices]:
    return list(Choices)

# and this no longer produces an error
def error_func() -> list[Choices]:
    var = list(Choices)
    return var

https://mypy-play.net/?mypy=latest&python=3.11&gist=3e91cdf18f5a07b47d6619cb43940b6c

This also seems to cover the issue @Apakottur found: https://mypy-play.net/?mypy=latest&python=3.11&gist=f32efd891ff8b25fa333c937093682e1

You can then use it like so:

# file path: project_root/fixes/enum.py

from typing import overload, Self
from enum import StrEnum as _StrEnum

__all__ = ['StrEnum', ]

class StrEnum(_StrEnum):
    @overload
    def __new__(cls, object: object = ...) -> Self: ...
    @overload
    def __new__(cls, object: object, encoding: str = ..., errors: str = ...) -> Self: ...

    def __new__(cls, *values):
        return _StrEnum._new_member_(cls, *values)
# file path: project_root/main.py

from fixes.enum import StrEnum

class Choices(StrEnum):
    LOREM = "lorem"
    IPSUM = "ipsum"

if __name__ == '__main__':
    print(Choices.__members__)
    # outputs:
    # {'LOREM': <Choices.LOREM: 'lorem'>, 'IPSUM': <Choices.IPSUM: 'ipsum'>}

I'm not sure how mypy works, but I think this issue could be resolved if StrEnum in enum.pyi, in the typeshed:

# current enum.pyi

class StrEnum(str, ReprEnum):
    def __new__(cls, value: str) -> Self: ...
    _value_: str
    @_magic_enum_attr
    def value(self) -> str: ...
    @staticmethod
    def _generate_next_value_(name: str, start: int, count: int, last_values: list[str]) -> str: ...

Was updated to account for str.__new__'s overloads:

# proposed enum.pyi

class StrEnum(str, ReprEnum):
    @overload
    def __new__(cls, object: object = ...) -> Self: ...
    @overload
    def __new__(cls, object: ReadableBuffer, encoding: str = ..., errors: str = ...) -> Self: ...
    _value_: str
    @_magic_enum_attr
    def value(self) -> str: ...
    @staticmethod
    def _generate_next_value_(name: str, start: int, count: int, last_values: list[str]) -> str: ...

I understand that the documentation for StrEnum.__new__ states "values must already be of type str", but if you look at the actual code you'll notice that StrEnum.__new__ is accounting for all of the current parameters to str.__new__:

def __new__(cls, *values):
    "values must already be of type `str`"
    if len(values) > 3:
        raise TypeError('too many arguments for str(): %r' % (values, ))
    if len(values) == 1:
        # it must be a string
        if not isinstance(values[0], str):
            raise TypeError('%r is not a string' % (values[0], ))
    if len(values) >= 2:
        # check that encoding argument is a string
        if not isinstance(values[1], str):
            raise TypeError('encoding must be a string, not %r' % (values[1], ))
    if len(values) == 3:
        # check that errors argument is a string
        if not isinstance(values[2], str):
            raise TypeError('errors must be a string, not %r' % (values[2]))
    value = str(*values)
    member = str.__new__(cls, value)
    member._value_ = value
    return member

Edit

Updating the enum.pyi typeshed file with the proposed updates doesn't seem to fix anything. Hopefully the workaround described above will help someone until the underlying issue gets resolved.

kikones34 commented 7 months ago

@jhenly your fix works, thanks! Although I really don't like having to import a custom StrEnum, we'll probably just keep silencing the errors for now.

jarmstrong-atlassian commented 5 months ago

I don't think this has anything to do with assigning StrEnum to a list. Contrary to the documentation, StrEnum just doesn't work (although on a closer reading I notice that the example is specific to Enum): https://mypy.readthedocs.io/en/stable/literal_types.html#enums

from enum import (
    StrEnum,
    Enum,
)
from typing import reveal_type

class TestEnum(Enum):
    ONE = 'one'

class TestStrEnum(StrEnum):
    ONE = 'one'

reveal_type(TestEnum.ONE)
reveal_type(TestStrEnum.ONE)

def test_enum(param: TestEnum):
    print(param)

def test_str_enum(param: TestStrEnum):
    print(param)

test_enum(TestEnum.ONE)
test_str_enum(TestStrEnum.ONE)

The output is:

mypy test_enum.py
test_enum.py:24: error: Argument 1 to "test_str_enum" has incompatible type "str"; expected "TestStrEnum"  [arg-type]
Python 3.11.7 (main, Jan 18 2024, 12:21:46) [GCC 11.4.0] on linux
Type "help", "copyright", "credits" or "license" for more information.
(InteractiveConsole)
>>> from test_enum import TestEnum
Runtime type is 'TestEnum'
Runtime type is 'TestStrEnum'
TestEnum.ONE
one
kikones34 commented 5 months ago

@jarmstrong-atlassian how are you performing the test? I get the following output with Python 3.11.1 and Mypy 1.9.0:

enum_test.py:14: note: Revealed type is "Literal[enum_test.TestEnum.ONE]?"
enum_test.py:15: note: Revealed type is "Literal[enum_test.TestStrEnum.ONE]?"
Success: no issues found in 1 source file
jarmstrong-atlassian commented 5 months ago

@kikones34 My bad. I was running mypy enum_test.py, but this was still in the directory with my current mypy.ini which was still setting python_version = 3.10. Removing that line, I get the expected result you posted. Sorry for the confusion.

tamird commented 3 months ago

Another weird case + a workaround that appeases mypy:

from collections.abc import Iterable
from enum import StrEnum

def takes_enum(e: StrEnum) -> StrEnum:
    return e

def takes_enum_type(type_: type[StrEnum]) -> StrEnum:
    return next(iter(type_)) # error: Incompatible return value type (got "str", expected "StrEnum")  [return-value]

def takes_enum_type_with_trick(type_: type[StrEnum]) -> StrEnum:
    return next(iter(mypy_type_hint_trick(type_))) # no error!

def mypy_type_hint_trick(type_: type[StrEnum]) -> Iterable[StrEnum]:
    return type_

gist