Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions crates/ty_python_semantic/resources/mdtest/enums.md
Original file line number Diff line number Diff line change
Expand Up @@ -320,6 +320,11 @@ reveal_type(enum_members(Answer))

reveal_type(Answer.YES.value) # revealed: Literal[1]
reveal_type(Answer.NO.value) # revealed: Literal[2]

class SingleMember(Enum):
SINGLE = auto()

reveal_type(SingleMember.SINGLE.value) # revealed: Literal[1]
```

Usages of `auto()` can be combined with manual value assignments:
Expand Down Expand Up @@ -348,6 +353,11 @@ class Answer(StrEnum):

reveal_type(Answer.YES.value) # revealed: Literal["yes"]
reveal_type(Answer.NO.value) # revealed: Literal["no"]

class SingleMember(StrEnum):
SINGLE = auto()

reveal_type(SingleMember.SINGLE.value) # revealed: Literal["single"]
```

Using `auto()` with `IntEnum` also works as expected:
Expand All @@ -363,6 +373,37 @@ reveal_type(Answer.YES.value) # revealed: Literal[1]
reveal_type(Answer.NO.value) # revealed: Literal[2]
```

Using `auto()` with non-integer mixins:

```python
from enum import Enum, auto

class A(str, Enum):
X = auto()
Y = auto()

reveal_type(A.X.value) # revealed: str

class B(bytes, Enum):
X = auto()
Y = auto()

reveal_type(B.X.value) # revealed: bytes

class C(tuple, Enum):
X = auto()
Y = auto()

# TODO: this should be `tuple`
reveal_type(C.X.value) # revealed: Literal[1]

class D(float, Enum):
X = auto()
Y = auto()

reveal_type(D.X.value) # revealed: float
```

Combining aliases with `auto()`:

```py
Expand Down
10 changes: 10 additions & 0 deletions crates/ty_python_semantic/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4024,6 +4024,16 @@ impl<'db> Type<'db> {
.into()
}

Type::NominalInstance(instance)
if matches!(name_str, "value" | "_value_")
&& is_single_member_enum(db, instance.class(db).class_literal(db).0) =>
{
enum_metadata(db, instance.class(db).class_literal(db).0)
.and_then(|metadata| metadata.members.get_index(0).map(|(_, v)| *v))
.map_or(Place::Unbound, Place::bound)
.into()
}

Type::NominalInstance(..)
| Type::ProtocolInstance(..)
| Type::BooleanLiteral(..)
Expand Down
22 changes: 17 additions & 5 deletions crates/ty_python_semantic/src/types/enums.rs
Original file line number Diff line number Diff line change
Expand Up @@ -77,9 +77,6 @@ pub(crate) fn enum_metadata<'db>(
return None;
}

let is_str_enum =
Type::ClassLiteral(class).is_subtype_of(db, KnownClass::StrEnum.to_subclass_of(db));

let scope_id = class.body_scope(db);
let use_def_map = use_def_map(db, scope_id);
let table = place_table(db, scope_id);
Expand Down Expand Up @@ -150,14 +147,29 @@ pub(crate) fn enum_metadata<'db>(
// enum.auto
Some(KnownClass::Auto) => {
auto_counter += 1;
Some(if is_str_enum {
let auto_value_ty = if Type::ClassLiteral(class)
.is_subtype_of(db, KnownClass::StrEnum.to_subclass_of(db))
{
Type::StringLiteral(StringLiteralType::new(
db,
name.to_lowercase().as_str(),
))
} else if Type::ClassLiteral(class)
.is_subtype_of(db, KnownClass::Str.to_subclass_of(db))
{
KnownClass::Str.to_instance(db)
} else if Type::ClassLiteral(class)
.is_subtype_of(db, KnownClass::Bytes.to_subclass_of(db))
{
KnownClass::Bytes.to_instance(db)
} else if Type::ClassLiteral(class)
.is_subtype_of(db, KnownClass::Float.to_subclass_of(db))
{
KnownClass::Float.to_instance(db)
Comment on lines +157 to +168
Copy link
Member

@AlexWaygood AlexWaygood Sep 26, 2025

Choose a reason for hiding this comment

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

hmm, I'm not sure we should be adding more special cases here. An enum class that uses bytes as a mixin isn't conceptually any different from an enum that uses tuple or list or dict as a mixin, or a custom dataclass or custom NamedTuple as a mixin. Why should we treat bytes and float specially here?

Rather than adding more special cases, I think we should have a generalised fallback logic that iterates through the MRO of the enum class to check whether it has a custom mixin that isn't str or int. (Those two make sense to special-case, I think, because of the presence of StrEnum and IntEnum in the stdlib. You can determine whether an element in the MRO is a "custom mixin" class by checking if it isn't object and isn't a subclass of enum.Enum -- if both those conditions hold, I think we can treat it as a mixin class.) If the enum class has a custom mixin and auto() is used to define the value of an enum member, we should probably infer the .value of that enum member as just being Any, which would still be more accurate than what we have on main (even if it isn't very precise!). It seems pretty hard to predict whether the .value property is going to give you an instance of the mixin or an integer:

>>> class Foo: ...
>>> import enum
>>> class FooEnum(Foo, enum.Enum):
...     X = enum.auto()
...     
>>> FooEnum.X.value
1
>>> class BEnum(bytes, enum.Enum):
...     X = enum.auto()
...     
>>> BEnum.X.value
b'\x00'
>>> class TEnum(tuple, enum.Enum):
...     X = enum.auto()
...     
>>> TEnum.X.value
(1,)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

this makes a lot more sense - thanks - didn't feel great about the explicit special cases after going down that road...

} else {
Type::IntLiteral(auto_counter)
})
};
Some(auto_value_ty)
}

_ => None,
Expand Down
Loading