[ty] Unwrap `enum.nonmember` values (#22025)

## Summary

This PR unwraps the `enum.nonmember` type to match runtime behavior.

Closes https://github.com/astral-sh/ty/issues/1974.
This commit is contained in:
Charlie Marsh 2025-12-18 19:59:49 -05:00 committed by GitHub
parent 5a2d3cda3d
commit 76854fdb15
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 80 additions and 6 deletions

View File

@ -451,6 +451,10 @@ class Answer(Enum):
# revealed: tuple[Literal["YES"], Literal["NO"]] # revealed: tuple[Literal["YES"], Literal["NO"]]
reveal_type(enum_members(Answer)) reveal_type(enum_members(Answer))
# `nonmember` attributes are unwrapped to the inner value type when accessed.
# revealed: int
reveal_type(Answer.OTHER)
``` ```
`member` can also be used as a decorator: `member` can also be used as a decorator:

View File

@ -20,7 +20,9 @@ use crate::types::bound_super::BoundSuperError;
use crate::types::constraints::{ConstraintSet, IteratorConstraintsExtension}; use crate::types::constraints::{ConstraintSet, IteratorConstraintsExtension};
use crate::types::context::InferContext; use crate::types::context::InferContext;
use crate::types::diagnostic::{INVALID_TYPE_ALIAS_TYPE, SUPER_CALL_IN_NAMED_TUPLE_METHOD}; use crate::types::diagnostic::{INVALID_TYPE_ALIAS_TYPE, SUPER_CALL_IN_NAMED_TUPLE_METHOD};
use crate::types::enums::enum_metadata; use crate::types::enums::{
enum_metadata, is_enum_class_by_inheritance, try_unwrap_nonmember_value,
};
use crate::types::function::{ use crate::types::function::{
DataclassTransformerFlags, DataclassTransformerParams, KnownFunction, DataclassTransformerFlags, DataclassTransformerParams, KnownFunction,
}; };
@ -2355,6 +2357,18 @@ impl<'db> ClassLiteral<'db> {
// The symbol was not found in the class scope. It might still be implicitly defined in `@classmethod`s. // The symbol was not found in the class scope. It might still be implicitly defined in `@classmethod`s.
return Self::implicit_attribute(db, body_scope, name, MethodDecorator::ClassMethod); return Self::implicit_attribute(db, body_scope, name, MethodDecorator::ClassMethod);
} }
// For enum classes, `nonmember(value)` creates a non-member attribute.
// At runtime, the enum metaclass unwraps the value, so accessing the attribute
// returns the inner value, not the `nonmember` wrapper.
if let Some(ty) = member.inner.place.ignore_possibly_undefined() {
if let Some(value_ty) = try_unwrap_nonmember_value(db, ty) {
if is_enum_class_by_inheritance(db, self) {
return Member::definitely_declared(value_ty);
}
}
}
member member
} }

View File

@ -60,11 +60,7 @@ pub(crate) fn enum_metadata<'db>(
return None; return None;
} }
if !Type::ClassLiteral(class).is_subtype_of(db, KnownClass::Enum.to_subclass_of(db)) if !is_enum_class_by_inheritance(db, class) {
&& !class
.metaclass(db)
.is_subtype_of(db, KnownClass::EnumType.to_subclass_of(db))
{
return None; return None;
} }
@ -294,3 +290,63 @@ pub(crate) fn is_enum_class<'db>(db: &'db dyn Db, ty: Type<'db>) -> bool {
_ => false, _ => false,
} }
} }
/// Checks if a class is an enum class by inheritance (either a subtype of `Enum`
/// or has a metaclass that is a subtype of `EnumType`).
///
/// This is a lighter-weight check than `enum_metadata`, which additionally
/// verifies that the class has members.
pub(crate) fn is_enum_class_by_inheritance<'db>(db: &'db dyn Db, class: ClassLiteral<'db>) -> bool {
Type::ClassLiteral(class).is_subtype_of(db, KnownClass::Enum.to_subclass_of(db))
|| class
.metaclass(db)
.is_subtype_of(db, KnownClass::EnumType.to_subclass_of(db))
}
/// Extracts the inner value type from an `enum.nonmember()` wrapper.
///
/// At runtime, the enum metaclass unwraps `nonmember(value)`, so accessing the attribute
/// returns the inner value, not the `nonmember` wrapper.
///
/// Returns `Some(value_type)` if the type is a `nonmember[T]`, otherwise `None`.
pub(crate) fn try_unwrap_nonmember_value<'db>(db: &'db dyn Db, ty: Type<'db>) -> Option<Type<'db>> {
match ty {
Type::NominalInstance(instance) if instance.has_known_class(db, KnownClass::Nonmember) => {
Some(
ty.member(db, "value")
.place
.ignore_possibly_undefined()
.unwrap_or(Type::unknown()),
)
}
Type::Union(union) => {
// TODO: This is a hack. The proper fix is to avoid unioning Unknown from
// declarations into Place when we have concrete bindings.
//
// For now, we filter out Unknown and expect exactly one nonmember type
// to remain. If there are other non-Unknown types mixed in, we bail out.
let mut non_unknown = union.elements(db).iter().filter(|elem| !elem.is_unknown());
let first = non_unknown.next()?;
// Ensure there's exactly one non-Unknown element.
if non_unknown.next().is_some() {
return None;
}
if let Type::NominalInstance(instance) = first {
if instance.has_known_class(db, KnownClass::Nonmember) {
return Some(
first
.member(db, "value")
.place
.ignore_possibly_undefined()
.unwrap_or(Type::unknown()),
);
}
}
None
}
_ => None,
}
}