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

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

Open
Nnonexistent opened this issue Feb 13, 2023 · 8 comments
Open
Labels
bug mypy got something wrong topic-enum

Comments

@Nnonexistent
Copy link

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

  • Mypy version used: 1.0.0
  • Mypy command-line flags: none
  • Mypy configuration options from mypy.ini (and other config files): none
  • Python version used: 3.11.1
@Nnonexistent Nnonexistent added the bug mypy got something wrong label Feb 13, 2023
@Apakottur
Copy link

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
Copy link

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
Copy link

jhenly commented Feb 2, 2024

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
Copy link

@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
Copy link

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
Copy link

@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
Copy link

@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
Copy link
Contributor

tamird commented May 31, 2024

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
bug mypy got something wrong topic-enum
Projects
None yet
Development

No branches or pull requests

7 participants