[ty] infer name and value for enum members (#20311)

## summary
- this pr implements the following attributes for `Enum` members:
  - `name`
  - `_name_`
  - `value`
  - `_value_`
- adds a TODO test for `my_enum_class_instance.name`
- only implements if the instance is a subclass of `Enum` re: this
[comment](https://github.com/astral-sh/ruff/pull/19481#issuecomment-3103460307)
and existing
[test](c34449ed7c/crates/ty_python_semantic/resources/mdtest/enums.md (L625))

### pointers
- https://github.com/astral-sh/ty/issues/876
- https://typing.python.org/en/latest/spec/enums.html#enum-definition
- https://github.com/astral-sh/ruff/pull/19481#issuecomment-3103460307

## test plan
- mdtests
- triaged conformance diffs here:
https://diffswarm.dev/d-01k531ag4nee3xmdeq4f3j66pb
- triaged mypy primer diffs here for django-stubs:
https://diffswarm.dev/d-01k5331n13k9yx8tvnxnkeawp3
  - added a TODO test for overriding `.value`
- discord diff seems reasonable

---------

Co-authored-by: David Peter <mail@david-peter.de>
This commit is contained in:
justin 2025-09-17 03:36:27 -04:00 committed by GitHub
parent c2fa449954
commit 9f0b942b9e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 132 additions and 28 deletions

View file

@ -2415,7 +2415,7 @@ class Answer(enum.Enum):
YES = 1
reveal_type(Answer.NO) # revealed: Literal[Answer.NO]
reveal_type(Answer.NO.value) # revealed: Any
reveal_type(Answer.NO.value) # revealed: Literal[0]
reveal_type(Answer.__members__) # revealed: MappingProxyType[str, Unknown]
```

View file

@ -4,6 +4,7 @@
```py
from enum import Enum
from typing import Literal
class Color(Enum):
RED = 1
@ -11,8 +12,8 @@ class Color(Enum):
BLUE = 3
reveal_type(Color.RED) # revealed: Literal[Color.RED]
# TODO: This could be `Literal[1]`
reveal_type(Color.RED.value) # revealed: Any
reveal_type(Color.RED.name) # revealed: Literal["RED"]
reveal_type(Color.RED.value) # revealed: Literal[1]
# TODO: Should be `Color` or `Literal[Color.RED]`
reveal_type(Color["RED"]) # revealed: Unknown
@ -155,6 +156,7 @@ python-version = "3.11"
```py
from enum import Enum, property as enum_property
from typing import Any
from ty_extensions import enum_members
class Answer(Enum):
@ -169,6 +171,22 @@ class Answer(Enum):
reveal_type(enum_members(Answer))
```
Enum attributes defined using `enum.property` take precedence over generated attributes.
```py
from enum import Enum, property as enum_property
class Choices(Enum):
A = 1
B = 2
@enum_property
def value(self) -> Any: ...
# TODO: This should be `Any` - overridden by `@enum_property`
reveal_type(Choices.A.value) # revealed: Literal[1]
```
### `types.DynamicClassAttribute`
Attributes defined using `types.DynamicClassAttribute` are not considered members:
@ -481,6 +499,62 @@ callable = Printer.STDERR
callable("Another error!")
```
## Special attributes on enum members
### `name` and `_name_`
```py
from enum import Enum
from typing import Literal
class Color(Enum):
RED = 1
GREEN = 2
BLUE = 3
reveal_type(Color.RED._name_) # revealed: Literal["RED"]
def _(red_or_blue: Literal[Color.RED, Color.BLUE]):
reveal_type(red_or_blue.name) # revealed: Literal["RED", "BLUE"]
def _(any_color: Color):
# TODO: Literal["RED", "GREEN", "BLUE"]
reveal_type(any_color.name) # revealed: Any
```
### `value` and `_value_`
```toml
[environment]
python-version = "3.11"
```
```py
from enum import Enum, StrEnum
from typing import Literal
class Color(Enum):
RED = 1
GREEN = 2
BLUE = 3
reveal_type(Color.RED.value) # revealed: Literal[1]
reveal_type(Color.RED._value_) # revealed: Literal[1]
reveal_type(Color.GREEN.value) # revealed: Literal[2]
reveal_type(Color.GREEN._value_) # revealed: Literal[2]
class Answer(StrEnum):
YES = "yes"
NO = "no"
reveal_type(Answer.YES.value) # revealed: Literal["yes"]
reveal_type(Answer.YES._value_) # revealed: Literal["yes"]
reveal_type(Answer.NO.value) # revealed: Literal["no"]
reveal_type(Answer.NO._value_) # revealed: Literal["no"]
```
## Properties of enum types
### Implicitly final
@ -609,6 +683,12 @@ reveal_type(EnumWithSubclassOfEnumMetaMetaclass.NO) # revealed: Literal[EnumWit
# Attributes like `.value` can *not* be accessed on members of these enums:
# error: [unresolved-attribute]
EnumWithSubclassOfEnumMetaMetaclass.NO.value
# error: [unresolved-attribute]
EnumWithSubclassOfEnumMetaMetaclass.NO._value_
# error: [unresolved-attribute]
EnumWithSubclassOfEnumMetaMetaclass.NO.name
# error: [unresolved-attribute]
EnumWithSubclassOfEnumMetaMetaclass.NO._name_
```
### Enums with (subclasses of) `EnumType` as metaclass

View file

@ -4,8 +4,6 @@
)]
use std::hash::BuildHasherDefault;
use rustc_hash::FxHasher;
use crate::lint::{LintRegistry, LintRegistryBuilder};
use crate::suppression::{INVALID_IGNORE_COMMENT, UNKNOWN_RULE, UNUSED_IGNORE_COMMENT};
pub use db::Db;
@ -19,6 +17,7 @@ pub use program::{
PythonVersionWithSource, SearchPathSettings,
};
pub use python_platform::PythonPlatform;
use rustc_hash::FxHasher;
pub use semantic_model::{
Completion, CompletionKind, HasDefinition, HasType, NameKind, SemanticModel,
};

View file

@ -3528,6 +3528,29 @@ impl<'db> Type<'db> {
.value_type(db)
.member_lookup_with_policy(db, name, policy),
Type::EnumLiteral(enum_literal)
if matches!(name_str, "name" | "_name_")
&& Type::ClassLiteral(enum_literal.enum_class(db))
.is_subtype_of(db, KnownClass::Enum.to_subclass_of(db)) =>
{
Place::bound(Type::StringLiteral(StringLiteralType::new(
db,
enum_literal.name(db).as_str(),
)))
.into()
}
Type::EnumLiteral(enum_literal)
if matches!(name_str, "value" | "_value_")
&& Type::ClassLiteral(enum_literal.enum_class(db))
.is_subtype_of(db, KnownClass::Enum.to_subclass_of(db)) =>
{
enum_metadata(db, enum_literal.enum_class(db))
.and_then(|metadata| metadata.members.get(enum_literal.name(db)))
.map_or_else(|| Place::Unbound, Place::bound)
.into()
}
Type::NominalInstance(..)
| Type::ProtocolInstance(..)
| Type::BooleanLiteral(..)

View file

@ -428,9 +428,8 @@ impl<'db> UnionBuilder<'db> {
let all_members_are_in_union = metadata
.members
.difference(&enum_members_in_union)
.next()
.is_none();
.keys()
.all(|name| enum_members_in_union.contains(name));
if all_members_are_in_union {
self.add_in_place_impl(

View file

@ -724,7 +724,7 @@ impl<'db> Bindings<'db> {
db,
metadata
.members
.iter()
.keys()
.map(|member| Type::string_literal(db, member)),
)
} else {

View file

@ -2,7 +2,7 @@ use ruff_python_ast::name::Name;
use rustc_hash::FxHashMap;
use crate::{
Db, FxOrderSet,
Db, FxIndexMap,
place::{Place, PlaceAndQualifiers, place_from_bindings, place_from_declarations},
semantic_index::{place_table, use_def_map},
types::{
@ -11,24 +11,24 @@ use crate::{
},
};
#[derive(Debug, PartialEq, Eq)]
pub(crate) struct EnumMetadata {
pub(crate) members: FxOrderSet<Name>,
#[derive(Debug, PartialEq, Eq, salsa::Update)]
pub(crate) struct EnumMetadata<'db> {
pub(crate) members: FxIndexMap<Name, Type<'db>>,
pub(crate) aliases: FxHashMap<Name, Name>,
}
impl get_size2::GetSize for EnumMetadata {}
impl get_size2::GetSize for EnumMetadata<'_> {}
impl EnumMetadata {
impl EnumMetadata<'_> {
fn empty() -> Self {
EnumMetadata {
members: FxOrderSet::default(),
members: FxIndexMap::default(),
aliases: FxHashMap::default(),
}
}
pub(crate) fn resolve_member<'a>(&'a self, name: &'a Name) -> Option<&'a Name> {
if self.members.contains(name) {
if self.members.contains_key(name) {
Some(name)
} else {
self.aliases.get(name)
@ -36,18 +36,21 @@ impl EnumMetadata {
}
}
#[allow(clippy::ref_option)]
fn enum_metadata_cycle_recover(
_db: &dyn Db,
_value: &Option<EnumMetadata>,
#[allow(clippy::ref_option, clippy::trivially_copy_pass_by_ref)]
fn enum_metadata_cycle_recover<'db>(
_db: &'db dyn Db,
_value: &Option<EnumMetadata<'db>>,
_count: u32,
_class: ClassLiteral<'_>,
) -> salsa::CycleRecoveryAction<Option<EnumMetadata>> {
_class: ClassLiteral<'db>,
) -> salsa::CycleRecoveryAction<Option<EnumMetadata<'db>>> {
salsa::CycleRecoveryAction::Iterate
}
#[allow(clippy::unnecessary_wraps)]
fn enum_metadata_cycle_initial(_db: &dyn Db, _class: ClassLiteral<'_>) -> Option<EnumMetadata> {
fn enum_metadata_cycle_initial<'db>(
_db: &'db dyn Db,
_class: ClassLiteral<'db>,
) -> Option<EnumMetadata<'db>> {
Some(EnumMetadata::empty())
}
@ -57,7 +60,7 @@ fn enum_metadata_cycle_initial(_db: &dyn Db, _class: ClassLiteral<'_>) -> Option
pub(crate) fn enum_metadata<'db>(
db: &'db dyn Db,
class: ClassLiteral<'db>,
) -> Option<EnumMetadata> {
) -> Option<EnumMetadata<'db>> {
// This is a fast path to avoid traversing the MRO of known classes
if class
.known(db)
@ -217,9 +220,9 @@ pub(crate) fn enum_metadata<'db>(
}
}
Some(name.clone())
Some((name.clone(), value_ty))
})
.collect::<FxOrderSet<_>>();
.collect::<FxIndexMap<_, _>>();
if members.is_empty() {
// Enum subclasses without members are not considered enums.
@ -237,7 +240,7 @@ pub(crate) fn enum_member_literals<'a, 'db: 'a>(
enum_metadata(db, class).map(|metadata| {
metadata
.members
.iter()
.keys()
.filter(move |name| Some(*name) != exclude_member)
.map(move |name| Type::EnumLiteral(EnumLiteralType::new(db, class, name.clone())))
})