Skip to content

Commit f63a9f2

Browse files
[ty] Fix incorrect inference of enum.auto() for enums with non-int mixins, and imprecise inference of enum.auto() for single-member enums (#20541)
Co-authored-by: Alex Waygood <alex.waygood@gmail.com>
1 parent e4dc406 commit f63a9f2

File tree

3 files changed

+104
-7
lines changed

3 files changed

+104
-7
lines changed

crates/ty_python_semantic/resources/mdtest/enums.md

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -320,6 +320,11 @@ reveal_type(enum_members(Answer))
320320

321321
reveal_type(Answer.YES.value) # revealed: Literal[1]
322322
reveal_type(Answer.NO.value) # revealed: Literal[2]
323+
324+
class SingleMember(Enum):
325+
SINGLE = auto()
326+
327+
reveal_type(SingleMember.SINGLE.value) # revealed: Literal[1]
323328
```
324329

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

349354
reveal_type(Answer.YES.value) # revealed: Literal["yes"]
350355
reveal_type(Answer.NO.value) # revealed: Literal["no"]
356+
357+
class SingleMember(StrEnum):
358+
SINGLE = auto()
359+
360+
reveal_type(SingleMember.SINGLE.value) # revealed: Literal["single"]
351361
```
352362

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

376+
As does using `auto()` for other enums that use `int` as a mixin:
377+
378+
```py
379+
from enum import Enum, auto
380+
381+
class Answer(int, Enum):
382+
YES = auto()
383+
NO = auto()
384+
385+
reveal_type(Answer.YES.value) # revealed: Literal[1]
386+
reveal_type(Answer.NO.value) # revealed: Literal[2]
387+
```
388+
389+
It's [hard to predict](https://github.com/astral-sh/ruff/pull/20541#discussion_r2381878613) what the
390+
effect of using `auto()` will be for an arbitrary non-integer mixin, so for anything that isn't a
391+
`StrEnum` and has a non-`int` mixin, we simply fallback to typeshed's annotation of `Any` for the
392+
`value` property:
393+
394+
```python
395+
from enum import Enum, auto
396+
397+
class A(str, Enum):
398+
X = auto()
399+
Y = auto()
400+
401+
reveal_type(A.X.value) # revealed: Any
402+
403+
class B(bytes, Enum):
404+
X = auto()
405+
Y = auto()
406+
407+
reveal_type(B.X.value) # revealed: Any
408+
409+
class C(tuple, Enum):
410+
X = auto()
411+
Y = auto()
412+
413+
reveal_type(C.X.value) # revealed: Any
414+
415+
class D(float, Enum):
416+
X = auto()
417+
Y = auto()
418+
419+
reveal_type(D.X.value) # revealed: Any
420+
```
421+
366422
Combining aliases with `auto()`:
367423

368424
```py

crates/ty_python_semantic/src/types.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4365,6 +4365,16 @@ impl<'db> Type<'db> {
43654365
Place::bound(todo_type!("ParamSpecArgs / ParamSpecKwargs")).into()
43664366
}
43674367

4368+
Type::NominalInstance(instance)
4369+
if matches!(name_str, "value" | "_value_")
4370+
&& is_single_member_enum(db, instance.class(db).class_literal(db).0) =>
4371+
{
4372+
enum_metadata(db, instance.class(db).class_literal(db).0)
4373+
.and_then(|metadata| metadata.members.get_index(0).map(|(_, v)| *v))
4374+
.map_or(Place::Undefined, Place::bound)
4375+
.into()
4376+
}
4377+
43684378
Type::NominalInstance(..)
43694379
| Type::ProtocolInstance(..)
43704380
| Type::BooleanLiteral(..)

crates/ty_python_semantic/src/types/enums.rs

Lines changed: 38 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ use crate::{
66
place::{Place, PlaceAndQualifiers, place_from_bindings, place_from_declarations},
77
semantic_index::{place_table, use_def_map},
88
types::{
9-
ClassLiteral, DynamicType, EnumLiteralType, KnownClass, MemberLookupPolicy,
9+
ClassBase, ClassLiteral, DynamicType, EnumLiteralType, KnownClass, MemberLookupPolicy,
1010
StringLiteralType, Type, TypeQualifiers,
1111
},
1212
};
@@ -68,9 +68,6 @@ pub(crate) fn enum_metadata<'db>(
6868
return None;
6969
}
7070

71-
let is_str_enum =
72-
Type::ClassLiteral(class).is_subtype_of(db, KnownClass::StrEnum.to_subclass_of(db));
73-
7471
let scope_id = class.body_scope(db);
7572
let use_def_map = use_def_map(db, scope_id);
7673
let table = place_table(db, scope_id);
@@ -141,14 +138,48 @@ pub(crate) fn enum_metadata<'db>(
141138
// enum.auto
142139
Some(KnownClass::Auto) => {
143140
auto_counter += 1;
144-
Some(if is_str_enum {
141+
142+
// `StrEnum`s have different `auto()` behaviour to enums inheriting from `(str, Enum)`
143+
let auto_value_ty = if Type::ClassLiteral(class)
144+
.is_subtype_of(db, KnownClass::StrEnum.to_subclass_of(db))
145+
{
145146
Type::StringLiteral(StringLiteralType::new(
146147
db,
147148
name.to_lowercase().as_str(),
148149
))
149150
} else {
150-
Type::IntLiteral(auto_counter)
151-
})
151+
let custom_mixins: smallvec::SmallVec<[Option<KnownClass>; 1]> =
152+
class
153+
.iter_mro(db, None)
154+
.skip(1)
155+
.filter_map(ClassBase::into_class)
156+
.filter(|class| {
157+
!Type::from(*class).is_subtype_of(
158+
db,
159+
KnownClass::Enum.to_subclass_of(db),
160+
)
161+
})
162+
.map(|class| class.known(db))
163+
.filter(|class| {
164+
!matches!(class, Some(KnownClass::Object))
165+
})
166+
.collect();
167+
168+
// `IntEnum`s have the same `auto()` behaviour to enums inheriting from `(int, Enum)`,
169+
// and `IntEnum`s also have `int` in their MROs, so both cases are handled here.
170+
//
171+
// In general, the `auto()` behaviour for enums with non-`int` mixins is hard to predict,
172+
// so we fall back to `Any` in those cases.
173+
if matches!(
174+
custom_mixins.as_slice(),
175+
[] | [Some(KnownClass::Int)]
176+
) {
177+
Type::IntLiteral(auto_counter)
178+
} else {
179+
Type::any()
180+
}
181+
};
182+
Some(auto_value_ty)
152183
}
153184

154185
_ => None,

0 commit comments

Comments
 (0)