Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
4f8515b
[ty] Normalize built-in enum mixin values
charliermarsh Jun 25, 2026
494aea7
[ty] Preserve union members during enum normalization
charliermarsh Jun 25, 2026
b68239a
[ty] Find enum data types through inherited bases
charliermarsh Jun 25, 2026
8f78400
[ty] Avoid coercing non-string StrEnum values
charliermarsh Jun 25, 2026
117f8d7
[ty] Reduce enum mixin normalization overhead
charliermarsh Jun 25, 2026
f5ea39f
[ty] Model StrEnum values with string normalization
charliermarsh Jun 25, 2026
6dddd64
[ty] Respect custom enum data mixin methods
charliermarsh Jun 25, 2026
4fed7e9
[ty] Fix enum alias normalization edge cases
charliermarsh Jun 26, 2026
56e82d1
[ty] Preserve custom enum data type precision
charliermarsh Jun 26, 2026
e496117
[ty] Simplify enum normalization mdtests
charliermarsh Jun 26, 2026
0b83ca3
[ty] Restrict enum normalization to built-in data types
charliermarsh Jun 26, 2026
f721851
[ty] Compare enum aliases using normalized values
charliermarsh Jun 26, 2026
dd15c15
[ty] Find enum data types per direct base
charliermarsh Jun 26, 2026
ac8c8fd
[ty] Preserve exact values for other built-in enum data types
charliermarsh Jun 26, 2026
668b36c
[ty] Treat unsupported enum data types conservatively
charliermarsh Jun 26, 2026
58c45e9
[ty] Treat imprecise enum aliases as unknown
charliermarsh Jun 26, 2026
974ff56
[ty] Preserve enum payloads beneath value annotations
charliermarsh Jun 26, 2026
f645d1b
[ty] Simplify enum data type tracking
charliermarsh Jun 26, 2026
af971f8
[ty] Avoid unstable if-let match guard
charliermarsh Jun 26, 2026
6d0daa5
[ty] Treat equality as unknown when enum aliases are unknown
charliermarsh Jun 26, 2026
779e27a
[ty] Exclude non-members from enum alias analysis
charliermarsh Jun 26, 2026
9c736d1
[ty] Avoid cloning enum declarations
charliermarsh Jun 26, 2026
b6ce575
[ty] Normalize functional enum member values
charliermarsh Jun 27, 2026
b271885
[ty] Preserve functional enum aliases after widening
charliermarsh Jun 27, 2026
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
202 changes: 191 additions & 11 deletions crates/ty_python_semantic/resources/mdtest/enums.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,9 @@ reveal_type(Color(1)) # revealed: Color
reveal_type(Color.RED in Color) # revealed: bool
```

Known standard-library enum constructors preserve literal `.value` types when they do not normalize
the declared value. The inherited `_value_` annotation remains the fallback when construction does
normalize the value or when accessing `.value` on the enum class as a whole:
For standard-library enum classes, we preserve literal `.value` types when we can model how the data
type constructs each value. The inherited `_value_` annotation remains the fallback when we cannot,
or when accessing `.value` on the enum class as a whole:

```py
from enum import IntEnum, auto
Expand All @@ -33,10 +33,12 @@ from typing import Literal
class Integer(IntEnum):
ONE = 1
TRUE = True
TWO = 2

reveal_type(Integer.ONE.value) # revealed: Literal[1]
reveal_type(Integer.ONE._value_) # revealed: Literal[1]
reveal_type(Integer.TRUE.value) # revealed: int
reveal_type(Integer.TRUE.value) # revealed: Literal[1]
reveal_type(Integer.TRUE) # revealed: Literal[Integer.ONE]

def _(value: Integer):
reveal_type(value.value) # revealed: int
Expand Down Expand Up @@ -773,8 +775,153 @@ class InheritedWeirdEnum(EmptyWeirdEnum):

reveal_type(InheritedWeirdEnum.FROM_BOOL.value) # revealed: Any
reveal_type(InheritedWeirdEnum.FROM_INT) # revealed: Literal[InheritedWeirdEnum.FROM_INT]
# revealed: tuple[Literal["FROM_BOOL"], Literal["FROM_INT"], Literal["OTHER"]]
reveal_type(enum_members(InheritedWeirdEnum))
reveal_type(enum_members(InheritedWeirdEnum)) # revealed: Unknown
```

### Built-in data types

An enum with an `int` or `str` data type stores the value produced by that type's constructor.
Aliases are determined from the constructed values rather than the original assignments:

```py
from enum import Enum
from ty_extensions import enum_members
from typing import Literal

class IntegerValues(int, Enum):
FALSE = False
ZERO = 0

reveal_type(IntegerValues.FALSE.value) # revealed: Literal[0]
# revealed: tuple[Literal["FALSE"]]
reveal_type(enum_members(IntegerValues))

class StringValues(str, Enum):
INTEGER = 1
STRING = "1"
BOOLEAN = False
BOOLEAN_STRING = "False"

reveal_type(StringValues.INTEGER.value) # revealed: Literal["1"]
reveal_type(StringValues.BOOLEAN.value) # revealed: Literal["False"]
# revealed: tuple[Literal["INTEGER"], Literal["BOOLEAN"]]
reveal_type(enum_members(StringValues))

def union_member_value(value: Literal[False, 2]):
class UnionValues(int, Enum):
MEMBER = value

reveal_type(UnionValues.MEMBER.value) # revealed: Literal[0, 2]

class IntegerBase(int, Enum):
pass

class InheritedValues(IntegerBase):
FALSE = False
ZERO = 0

reveal_type(InheritedValues.FALSE.value) # revealed: Literal[0]
# revealed: tuple[Literal["FALSE"]]
reveal_type(enum_members(InheritedValues))
```

Non-member declarations do not make alias detection inconclusive:

```py
from enum import Enum
from ty_extensions import enum_members

class ValuesWithHelper(int, Enum):
VALUE = 1

class Helper:
pass

ALIAS = 1

# revealed: tuple[Literal["VALUE"]]
reveal_type(enum_members(ValuesWithHelper))
```

When a built-in conversion cannot be modeled precisely, its aliases remain unknown:

```py
from enum import Enum
from ty_extensions import enum_members

class ParsedIntegerValues(int, Enum):
FIRST = "1"
SECOND = "1"

reveal_type(ParsedIntegerValues.FIRST is ParsedIntegerValues.SECOND) # revealed: bool
reveal_type(enum_members(ParsedIntegerValues)) # revealed: Unknown
```

Other built-in data types retain exact assigned values when no coercion is needed:

```py
from enum import Enum
from ty_extensions import enum_members

class ByteValues(bytes, Enum):
VALUE = b"value"
ALIAS = b"value"

reveal_type(ByteValues.VALUE.value) # revealed: Literal[b"value"]
# revealed: tuple[Literal["VALUE"]]
reveal_type(enum_members(ByteValues))
```

If the data type would coerce the assigned value, its value and aliases remain unknown:

```py
from enum import Enum
from ty_extensions import enum_members

class CoercingByteValues(bytes, Enum):
FROM_INT = 1
FROM_BYTES = b"\0"

reveal_type(CoercingByteValues.FROM_INT.value) # revealed: Any
reveal_type(CoercingByteValues.FROM_INT is CoercingByteValues.FROM_BYTES) # revealed: bool
reveal_type(enum_members(CoercingByteValues)) # revealed: Unknown
```

### User-defined data types

User-defined data types remain opaque even when they inherit from `int` or `str` without overriding
any methods. Their construction, attribute access, equality, and hashing can all differ from the
built-in type:

```py
from enum import Enum
from ty_extensions import enum_members

class CustomInt(int):
pass

class CustomValues(CustomInt, Enum):
FALSE = False
ZERO = 0

reveal_type(CustomValues.FALSE.value) # revealed: Any
reveal_type(CustomValues.FALSE is CustomValues.ZERO) # revealed: bool
reveal_type(CustomValues.ZERO.name) # revealed: str
reveal_type(enum_members(CustomValues)) # revealed: Unknown
```

A user-defined behavior base can still affect member construction and attribute access, so it keeps
the enum's values opaque even when a separate base selects a built-in data type:

```py
class Behavior:
pass

class ValuesWithBehavior(Behavior, int, Enum):
FALSE = False
ZERO = 0

reveal_type(ValuesWithBehavior.FALSE.value) # revealed: Any
```

### Assigned `__new__`
Expand Down Expand Up @@ -1318,10 +1465,10 @@ reveal_type(Answer.YES.value) # revealed: Literal[1]
reveal_type(Answer.NO.value) # revealed: Literal[2]
```

It's [hard to predict](https://github.com/astral-sh/ruff/pull/20541#discussion_r2381878613) what the
effect of using `auto()` will be for an arbitrary non-integer mixin, so for anything that isn't a
`StrEnum` and has a non-`int` mixin, we simply fallback to typeshed's annotation of `Any` for the
`value` property:
For an enum with a `str` data type, the generated value is still normalized to `str`. The result of
using `auto()` with other non-integer data types is
[hard to predict](https://github.com/astral-sh/ruff/pull/20541#discussion_r2381878613), so we use
typeshed's `Any` annotation for `.value` in those cases:

```python
from enum import Enum, auto
Expand All @@ -1331,7 +1478,7 @@ class A(str, Enum):
X = auto()
Y = auto()

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

class B(bytes, Enum):
X = auto()
Expand Down Expand Up @@ -1661,6 +1808,26 @@ def _inherited_mixed_instance(x: InheritedCustomNextValueChild):
reveal_type(x.value) # revealed: str | Literal[1]
```

### `auto()` after an alias

Even when a declaration becomes an alias, its original value is included in the `last_values` passed
to `_generate_next_value_`. Here, `TRUE` is an alias of `ONE`, but `AFTER` still receives the value
`2`:

```py
from enum import Enum, auto
from ty_extensions import enum_members

class Mixed(int, Enum):
ONE = 1
TRUE = True
AFTER = auto()

reveal_type(Mixed.AFTER.value) # revealed: Literal[2]
# revealed: tuple[Literal["ONE"], Literal["AFTER"]]
reveal_type(enum_members(Mixed))
```

### `member` and `nonmember`

```toml
Expand Down Expand Up @@ -2847,6 +3014,19 @@ Color = IntEnum("Color", "RED GREEN BLUE")

# revealed: tuple[Literal["RED"], Literal["GREEN"], Literal["BLUE"]]
reveal_type(enum_members(Color))

Number = IntEnum("Number", {"FALSE": False, "ZERO": 0})

reveal_type(Number.FALSE.value) # revealed: Literal[0]
reveal_type(Number.ZERO) # revealed: Number
reveal_type(enum_members(Number)) # revealed: tuple[Literal["FALSE"]]

# `int("1")` widens to `int`, but identical raw values still prove that the
# members are aliases.
Parsed = IntEnum("Parsed", {"A": "1", "B": "1"})

reveal_type(Parsed.B) # revealed: Parsed
reveal_type(enum_members(Parsed)) # revealed: tuple[Literal["A"]]
```

### Flag function syntax
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -194,16 +194,30 @@ class RuntimeIntAlias(IntEnum):
reveal_type(RuntimeIntAlias.FIRST == RuntimeIntAlias.SECOND) # revealed: Literal[True]
```

A scalar mixin can normalize member values before `Enum` checks for aliases. Here, `str` converts
`1` to `"1"`, so the two members are aliases at runtime. Since ty does not model this constructor
call, the comparison remains `bool`:
An enum with a `str` data type constructs its values before checking for aliases. Here, `str`
converts `1` to `"1"`, so the two members are aliases:

```py
class CoercingAlias(str, Enum):
FIRST = 1
SECOND = "1"

reveal_type(CoercingAlias.FIRST == CoercingAlias.SECOND) # revealed: bool
reveal_type(CoercingAlias.FIRST == CoercingAlias.SECOND) # revealed: Literal[True]
reveal_type(CoercingAlias.SECOND == "1") # revealed: Literal[True]
```

When alias detection is inconclusive, equality between different declarations is also unknown. The
two declarations below are aliases at runtime:

```py
class Behavior:
pass

class OpaqueAliases(Behavior, Enum):
FIRST = 1
SECOND = 1

reveal_type(OpaqueAliases.FIRST == OpaqueAliases.SECOND) # revealed: bool
```

Equality can transfer restrictions on enum members, but other intersection elements must stay on the
Expand Down Expand Up @@ -533,6 +547,12 @@ class IntegerKey(IntEnum):
ZERO = 0

reveal_type(BooleanKey.FALSE == IntegerKey.ZERO) # revealed: Literal[True]

class IntegerAliases(IntEnum):
ZERO = 0
FALSE = False

reveal_type(IntegerAliases.ZERO == IntegerAliases.FALSE) # revealed: Literal[True]
```

Plain enum members from different classes use identity comparison, even when their declared values
Expand Down Expand Up @@ -663,9 +683,22 @@ def _(value: Foo | Shifted):
reveal_type(value) # revealed: Literal[Foo.Y] | Shifted
```

An explicit `_value_` annotation can make `.value` precise, but it does not describe the scalar
payload used by inherited comparison methods. We therefore cannot compare members transformed by a
custom constructor using their annotated values:
An explicit `_value_` annotation controls the public `.value` type without erasing a concrete
comparison payload:

```py
from enum import IntEnum

class AnnotatedInteger(IntEnum):
_value_: int
ONE = 1

reveal_type(AnnotatedInteger.ONE.value) # revealed: int
reveal_type(AnnotatedInteger.ONE == 1) # revealed: Literal[True]
```

When a custom constructor transforms the member, however, the annotation does not describe the
scalar payload used by inherited comparison methods:

```py
from enum import IntEnum
Expand Down
4 changes: 3 additions & 1 deletion crates/ty_python_semantic/src/types/call/bind.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2065,7 +2065,9 @@ impl<'db> Bindings<'db> {
if let [Some(ty)] = overload.parameter_types() {
let return_ty = match ty {
Type::ClassLiteral(class) => {
if let Some(metadata) = enums::enum_metadata(db, *class) {
if let Some(metadata) = enums::enum_metadata(db, *class)
&& metadata.aliases_are_known
{
Type::heterogeneous_tuple(
db,
metadata
Expand Down
7 changes: 2 additions & 5 deletions crates/ty_python_semantic/src/types/class/enum_literal.rs
Original file line number Diff line number Diff line change
Expand Up @@ -241,14 +241,11 @@ impl<'db> DynamicEnumLiteral<'db> {
pub(super) fn own_class_member(self, db: &'db dyn Db, name: &str) -> Member<'db> {
let spec = self.spec(db);
if spec.has_known_members(db)
&& spec
.members(db)
.iter()
.any(|(member_name, _)| member_name == name)
&& let Some(enum_class) = ClassLiteral::DynamicEnum(self).into_enum_class(db)
&& let Some(canonical_name) = enum_class.resolve_member(db, &Name::new(name))
{
let enum_lit =
crate::types::literal::EnumLiteralType::new(db, enum_class, Name::new(name));
crate::types::literal::EnumLiteralType::new(db, enum_class, canonical_name.clone());
return Member::definitely_declared(Type::enum_literal(enum_lit));
}
Member::unbound()
Expand Down
Loading
Loading