From 7b59cb83f01f55ec5668a43ff3c16adb626cca6c Mon Sep 17 00:00:00 2001 From: Mangesh Raut Date: Tue, 27 Oct 2026 20:12:51 +0000 Subject: [PATCH 1/7] fix(enums): reject incompatible Enum value-lookup arguments EnumMeta.__call__ types `value` as Any, so Category(None) and similar calls produced no diagnostic even when a class-defined __init__ (or the inferred member value type) clearly rejects the argument. Refine value-lookup calls against that __init__ when present, otherwise against the non-Any member value type, without routing through __new__ (member construction at class definition time). Fixes #5398 --- pyrefly/lib/alt/call.rs | 113 +++++++++++++++++++++++++++++++++ pyrefly/lib/alt/class/enums.rs | 2 +- pyrefly/lib/test/enums.rs | 48 ++++++++++++++ 3 files changed, 162 insertions(+), 1 deletion(-) diff --git a/pyrefly/lib/alt/call.rs b/pyrefly/lib/alt/call.rs index b0433739a1..fa9040b072 100644 --- a/pyrefly/lib/alt/call.rs +++ b/pyrefly/lib/alt/call.rs @@ -1033,6 +1033,110 @@ impl<'ctx, 'answer, Ans: LookupAnswer> AnswersSolver<'ctx, 'answer, Ans> { ) } + /// EnumMeta.__call__ types the value parameter as Any. For value-lookup calls + /// (`MyEnum(x)`), refine against a class-defined `__init__` first parameter when + /// present, otherwise against the inferred member value type when it is not Any. + /// Do not use `__new__` here: it is for member construction at class definition time + /// (see SeFileType), while call sites look up by `_value_`. + fn check_enum_value_lookup_call( + &self, + cls: &ClassType, + args: &[CallArg], + keywords: &[CallKeyword], + arguments_range: TextRange, + errors: &ErrorCollector, + context: Option<&dyn Fn() -> ErrorContext>, + ) { + // Functional API uses a `names=` argument; leave that to EnumMeta.__call__. + if keywords.iter().any(|kw| { + kw.arg.is_some_and(|id| id.as_str() == "names") + }) { + return; + } + + if let Some(init) = self.enum_custom_init_for_value_lookup(cls) { + let _ = self.call_infer( + self.as_call_target_or_error( + init, + CallStyle::Method(&dunder::INIT), + arguments_range, + errors, + context, + ), + args, + keywords, + arguments_range, + errors, + context, + None, + None, + ); + return; + } + + let Some(want) = self.enum_value_lookup_expected_arg_type(cls) else { + return; + }; + if want.is_any() { + return; + } + + // Single positional value, or `value=` keyword (EnumMeta value-lookup form). + let value_arg = if args.len() == 1 && keywords.is_empty() { + match &args[0] { + CallArg::Arg(v) => Some(v), + CallArg::Star(..) => None, + } + } else if args.is_empty() { + keywords.iter().find_map(|kw| { + if kw.arg.is_some_and(|id| id.as_str() == "value") { + Some(&kw.value) + } else { + None + } + }) + } else { + None + }; + let Some(value_arg) = value_arg else { + return; + }; + let got = value_arg.infer(self, errors); + self.check_type_as_call_argument(&got, &want, value_arg.range(), errors, &|| { + let mut tcc = TypeCheckContext::of_kind(TypeCheckKind::CallArgument( + Some(Name::new_static("value")), + None, + )); + if let Some(ctx) = context { + tcc = tcc.with_context(Some(ctx())); + } + tcc + }); + } + + /// `__init__` defined on the user enum (not object / enum.Enum*), used to type value lookup. + fn enum_custom_init_for_value_lookup(&self, cls: &ClassType) -> Option { + let member = self.get_class_member_with_defining_class(cls.class_object(), &dunder::INIT)?; + let definer = &member.defining_class; + if definer.has_toplevel_qname("builtins", "object") + || definer.has_toplevel_qname(ModuleName::enum_().as_str(), "Enum") + || definer.has_toplevel_qname(ModuleName::enum_().as_str(), "IntEnum") + || definer.has_toplevel_qname(ModuleName::enum_().as_str(), "StrEnum") + || definer.has_toplevel_qname(ModuleName::enum_().as_str(), "Flag") + || definer.has_toplevel_qname(ModuleName::enum_().as_str(), "IntFlag") + || definer.has_toplevel_qname(ModuleName::enum_().as_str(), "ReprEnum") + { + return None; + } + self.get_dunder_init(cls, false) + } + + fn enum_value_lookup_expected_arg_type(&self, cls: &ClassType) -> Option { + let metadata = self.get_metadata_for_class(cls.class_object()); + let enum_metadata = metadata.enum_metadata()?; + Some(self.enum_value_lookup_on_class(cls, enum_metadata)) + } + /// If the metaclass defines a custom `__call__`, call it. If the `__call__` comes from `type`, ignore /// it because `type.__call__` behavior is baked into our constructor logic. fn call_metaclass( @@ -1294,7 +1398,16 @@ impl<'ctx, 'answer, Ans: LookupAnswer> AnswersSolver<'ctx, 'answer, Ans> { // Enum construction is routed through EnumMeta.__call__, which performs // member lookup by value. A custom enum __new__ is used for member creation // during class definition and should not be re-applied at call sites. + // EnumMeta types `value` as Any; refine against __init__ / member value types. if class_metadata.is_enum() { + self.check_enum_value_lookup_call( + &cls, + args, + keywords, + arguments_range, + &errors, + context, + ); let ty = if constructor_kind == ConstructorKind::TypeOfSelf { self.heap.mk_self_type(cls) } else { diff --git a/pyrefly/lib/alt/class/enums.rs b/pyrefly/lib/alt/class/enums.rs index eece67ec80..dbe5190dc4 100644 --- a/pyrefly/lib/alt/class/enums.rs +++ b/pyrefly/lib/alt/class/enums.rs @@ -286,7 +286,7 @@ impl<'ctx, 'answer, Ans: LookupAnswer> AnswersSolver<'ctx, 'answer, Ans> { /// Look up the `_value_` attribute for an enum type (not a specific member). /// Whether `_value_` should be read-write is unspecified, but we need to allow assigning /// it in `__init__` so we make it read-write. - fn enum_value_lookup_on_class(&self, class: &ClassType, enum_metadata: &EnumMetadata) -> Type { + pub(crate) fn enum_value_lookup_on_class(&self, class: &ClassType, enum_metadata: &EnumMetadata) -> Type { let mixed_in = self.mixed_in_enum_data_type(class.class_object()); let has_new = self .get_class_fields(class.class_object()) diff --git a/pyrefly/lib/test/enums.rs b/pyrefly/lib/test/enums.rs index 5a0bba5683..7e5c19cb84 100644 --- a/pyrefly/lib/test/enums.rs +++ b/pyrefly/lib/test/enums.rs @@ -1393,3 +1393,51 @@ E = Enum("E", [("A", (x := 1))], qualname=(x := "q")) assert_type(x, Literal["q"]) "#, ); + +testcase!( + test_enum_call_rejects_incompatible_value, + r#" +from enum import Enum +from typing import TYPE_CHECKING, Self + +class Category(Enum): + if TYPE_CHECKING: + @property + def value(self) -> str: ... + + def __init__(self, value: str) -> None: + self._id: str + else: + def __init__(self, value: str, id: str) -> None: + self._value_ = value + self._id = id + + GAMES = "GAMES", "0_0" + MUSIC = "MUSIC", "1_1" + + @property + def id(self) -> str: + return self._id + + @classmethod + def _missing_(cls, value: object) -> Self: + raise ValueError(value) + +Category("GAMES") +Category(None) # E: Argument `None` is not assignable to parameter `value` with type `str` + "#, +); + +testcase!( + test_enum_call_rejects_none_for_int_members, + r#" +from enum import Enum + +class Color(Enum): + RED = 1 + GREEN = 2 + +Color(1) +Color(None) # E: Argument `None` is not assignable to parameter `value` with type `Literal[1, 2]` + "#, +); From 23e3af28c43dcc15f0cc381d8b5e6011e4f08653 Mon Sep 17 00:00:00 2001 From: Mangesh Raut Date: Tue, 27 Oct 2026 20:32:11 +0000 Subject: [PATCH 2/7] style(enums): rustfmt enum value-lookup tip CI failed cargo fmt --check on the Enum call/lookup changes. --- pyrefly/lib/alt/call.rs | 10 ++++++---- pyrefly/lib/alt/class/enums.rs | 6 +++++- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/pyrefly/lib/alt/call.rs b/pyrefly/lib/alt/call.rs index fa9040b072..dbafa7074b 100644 --- a/pyrefly/lib/alt/call.rs +++ b/pyrefly/lib/alt/call.rs @@ -1048,9 +1048,10 @@ impl<'ctx, 'answer, Ans: LookupAnswer> AnswersSolver<'ctx, 'answer, Ans> { context: Option<&dyn Fn() -> ErrorContext>, ) { // Functional API uses a `names=` argument; leave that to EnumMeta.__call__. - if keywords.iter().any(|kw| { - kw.arg.is_some_and(|id| id.as_str() == "names") - }) { + if keywords + .iter() + .any(|kw| kw.arg.is_some_and(|id| id.as_str() == "names")) + { return; } @@ -1116,7 +1117,8 @@ impl<'ctx, 'answer, Ans: LookupAnswer> AnswersSolver<'ctx, 'answer, Ans> { /// `__init__` defined on the user enum (not object / enum.Enum*), used to type value lookup. fn enum_custom_init_for_value_lookup(&self, cls: &ClassType) -> Option { - let member = self.get_class_member_with_defining_class(cls.class_object(), &dunder::INIT)?; + let member = + self.get_class_member_with_defining_class(cls.class_object(), &dunder::INIT)?; let definer = &member.defining_class; if definer.has_toplevel_qname("builtins", "object") || definer.has_toplevel_qname(ModuleName::enum_().as_str(), "Enum") diff --git a/pyrefly/lib/alt/class/enums.rs b/pyrefly/lib/alt/class/enums.rs index dbe5190dc4..1b4f70ec50 100644 --- a/pyrefly/lib/alt/class/enums.rs +++ b/pyrefly/lib/alt/class/enums.rs @@ -286,7 +286,11 @@ impl<'ctx, 'answer, Ans: LookupAnswer> AnswersSolver<'ctx, 'answer, Ans> { /// Look up the `_value_` attribute for an enum type (not a specific member). /// Whether `_value_` should be read-write is unspecified, but we need to allow assigning /// it in `__init__` so we make it read-write. - pub(crate) fn enum_value_lookup_on_class(&self, class: &ClassType, enum_metadata: &EnumMetadata) -> Type { + pub(crate) fn enum_value_lookup_on_class( + &self, + class: &ClassType, + enum_metadata: &EnumMetadata, + ) -> Type { let mixed_in = self.mixed_in_enum_data_type(class.class_object()); let has_new = self .get_class_fields(class.class_object()) From 856949d8fd9822afb066ff1e6c3738638ba75252 Mon Sep 17 00:00:00 2001 From: Mangesh Raut Date: Tue, 27 Oct 2026 21:04:05 +0000 Subject: [PATCH 3/7] fix(enums): promote enum value-lookup arg type; accept members Literal member unions describe which values exist, but Enum(x) accepts any value of the promoted base type at type-check time and raises only when the concrete value is missing. Promote literals so `cls(val: str)` stays valid, and accept an already-constructed member (`cls(cls.A)`). Fixes CI red on test_access_value_on_mixed_type_enum and test_enum_classmethod after the reject-none tip. --- pyrefly/lib/alt/call.rs | 8 +++++++- pyrefly/lib/test/enums.rs | 2 +- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/pyrefly/lib/alt/call.rs b/pyrefly/lib/alt/call.rs index dbafa7074b..4bb150429b 100644 --- a/pyrefly/lib/alt/call.rs +++ b/pyrefly/lib/alt/call.rs @@ -1136,7 +1136,13 @@ impl<'ctx, 'answer, Ans: LookupAnswer> AnswersSolver<'ctx, 'answer, Ans> { fn enum_value_lookup_expected_arg_type(&self, cls: &ClassType) -> Option { let metadata = self.get_metadata_for_class(cls.class_object()); let enum_metadata = metadata.enum_metadata()?; - Some(self.enum_value_lookup_on_class(cls, enum_metadata)) + let value_ty = self.enum_value_lookup_on_class(cls, enum_metadata); + // Member literals (`Literal["a", "b"]`) describe which values exist, but + // Enum(x) accepts any value of that base type (`str`) and raises at + // runtime when the value is missing. Promote so `cls(val: str)` stays + // valid. Also accept an already-constructed member (`cls(cls.A)`). + let promoted = value_ty.promote_implicit_literals(self.stdlib); + Some(self.unions(vec![promoted, Type::ClassType(cls.clone())])) } /// If the metaclass defines a custom `__call__`, call it. If the `__call__` comes from `type`, ignore diff --git a/pyrefly/lib/test/enums.rs b/pyrefly/lib/test/enums.rs index 7e5c19cb84..4dc75fb115 100644 --- a/pyrefly/lib/test/enums.rs +++ b/pyrefly/lib/test/enums.rs @@ -1438,6 +1438,6 @@ class Color(Enum): GREEN = 2 Color(1) -Color(None) # E: Argument `None` is not assignable to parameter `value` with type `Literal[1, 2]` +Color(None) # E: Argument `None` is not assignable to parameter `value` with type `Color | int` "#, ); From 521631bddefc437798228a86670b08d58837e4fa Mon Sep 17 00:00:00 2001 From: Mangesh Raut Date: Sun, 1 Nov 2026 13:28:39 +0000 Subject: [PATCH 4/7] enums: drop __init__-based lookup check and infer lookup arg once Value lookup (MyEnum(x)) goes through EnumMeta.__call__ -> Enum.__new__, so the enum's own __init__/__new__ are irrelevant at call sites. Remove the __init__-based check entirely and only refine against the member value type when the call contract is stock EnumMeta: no custom metaclass, no user _missing_, no user __new__, and a non-Any value type. Numeric values also accept float/complex since lookup uses equality (1.0 == 1). Infer the lookup argument once and pass the inferred type to the metaclass __call__ check, avoiding exponential re-inference for nested enum calls. Add regression tests for tuple values with multi-param __init__, lookup by member and value=, __new__ changing the value type, custom metaclass and _missing_, cross-numeric equality, and deeply nested calls. --- pyrefly/lib/alt/call.rs | 204 ++++++++++++++++++++------------------ pyrefly/lib/test/enums.rs | 148 +++++++++++++++++++++++---- 2 files changed, 231 insertions(+), 121 deletions(-) diff --git a/pyrefly/lib/alt/call.rs b/pyrefly/lib/alt/call.rs index 4bb150429b..a777c2a6a6 100644 --- a/pyrefly/lib/alt/call.rs +++ b/pyrefly/lib/alt/call.rs @@ -1033,77 +1033,97 @@ impl<'ctx, 'answer, Ans: LookupAnswer> AnswersSolver<'ctx, 'answer, Ans> { ) } - /// EnumMeta.__call__ types the value parameter as Any. For value-lookup calls - /// (`MyEnum(x)`), refine against a class-defined `__init__` first parameter when - /// present, otherwise against the inferred member value type when it is not Any. - /// Do not use `__new__` here: it is for member construction at class definition time - /// (see SeFileType), while call sites look up by `_value_`. - fn check_enum_value_lookup_call( + /// `EnumMeta.__call__` types its `value` parameter as `Any`. For a plain value lookup + /// (`MyEnum(x)` / `MyEnum(value=x)`), return the type the lookup argument should be + /// checked against, or `None` when we cannot soundly refine it. + /// + /// Value lookup goes `EnumMeta.__call__` -> `Enum.__new__` -> existing member, + /// `_missing_(value)`, or `ValueError`. The enum's own `__init__`/`__new__` only run at + /// class-definition time, so they are deliberately not consulted here. We bail out when + /// the call contract may differ from stock `EnumMeta`: a custom metaclass, a user-defined + /// `_missing_`, the functional API, or an `Any` value type (e.g. a `__new__` that + /// rewrites `_value_` without a data-type mixin). + fn enum_value_lookup_expected_arg_type( &self, cls: &ClassType, - args: &[CallArg], keywords: &[CallKeyword], - arguments_range: TextRange, - errors: &ErrorCollector, - context: Option<&dyn Fn() -> ErrorContext>, - ) { - // Functional API uses a `names=` argument; leave that to EnumMeta.__call__. + ) -> Option { if keywords .iter() - .any(|kw| kw.arg.is_some_and(|id| id.as_str() == "names")) + .any(|kw| kw.arg.is_none_or(|id| id.as_str() != "value")) { - return; + return None; } - - if let Some(init) = self.enum_custom_init_for_value_lookup(cls) { - let _ = self.call_infer( - self.as_call_target_or_error( - init, - CallStyle::Method(&dunder::INIT), - arguments_range, - errors, - context, - ), - args, - keywords, - arguments_range, - errors, - context, - None, - None, - ); - return; + let metadata = self.get_metadata_for_class(cls.class_object()); + let enum_metadata = metadata.enum_metadata()?; + let enum_module = ModuleName::enum_(); + let is_stock_metaclass = metadata.custom_metaclass().is_some_and(|m| { + m.class_object() + .has_toplevel_qname(enum_module.as_str(), "EnumMeta") + || m.class_object() + .has_toplevel_qname(enum_module.as_str(), "EnumType") + }); + if !is_stock_metaclass { + return None; } - - let Some(want) = self.enum_value_lookup_expected_arg_type(cls) else { - return; - }; - if want.is_any() { - return; + if let Some(missing) = self.get_class_member_with_defining_class( + cls.class_object(), + &Name::new_static("_missing_"), + ) && missing.defining_class.module_name() != enum_module + { + return None; + } + // A user-defined `__new__` can store a `_value_` of any type, regardless of mixin. + if let Some(new) = + self.get_class_member_with_defining_class(cls.class_object(), &dunder::NEW) + && new.defining_class.module_name() != enum_module + && new.defining_class.module_name() != ModuleName::builtins() + { + return None; + } + let value_ty = self.enum_value_lookup_on_class(cls, enum_metadata); + if value_ty.is_any() { + return None; } + // Member literals say which values exist, but lookup accepts any value of the base + // type and raises at runtime when it is missing, so promote literals. + let promoted = value_ty.promote_implicit_literals(self.stdlib); + let mut options = vec![promoted.clone(), Type::ClassType(cls.clone())]; + // Lookup uses equality, so numeric values compare equal across int/float/complex + // (`Color(1.0)` finds a member whose value is `1`). + let int = self.heap.mk_class_type(self.stdlib.int().clone()); + let float = self.heap.mk_class_type(self.stdlib.float().clone()); + if self.is_subset_eq(&int, &promoted) || self.is_subset_eq(&float, &promoted) { + options.push(float); + options.push(self.heap.mk_class_type(self.stdlib.complex().clone())); + } + Some(self.unions(options)) + } - // Single positional value, or `value=` keyword (EnumMeta value-lookup form). - let value_arg = if args.len() == 1 && keywords.is_empty() { - match &args[0] { - CallArg::Arg(v) => Some(v), - CallArg::Star(..) => None, - } - } else if args.is_empty() { - keywords.iter().find_map(|kw| { - if kw.arg.is_some_and(|id| id.as_str() == "value") { - Some(&kw.value) - } else { - None - } - }) - } else { - None - }; - let Some(value_arg) = value_arg else { - return; + /// Check the value-lookup argument of an enum call against the expected lookup type. + /// Returns rewritten args/keywords that carry the already-inferred argument type, so the + /// metaclass `__call__` check does not infer the argument expression a second time + /// (re-inferring is exponential for nested enum calls). + fn check_enum_value_lookup_arg<'b>( + &self, + cls: &ClassType, + args: &[CallArg<'b>], + keywords: &[CallKeyword<'b>], + errors: &ErrorCollector, + context: Option<&dyn Fn() -> ErrorContext>, + ) -> Option<(Type, TextRange, bool)> { + let value_expr = match (args, keywords) { + ([CallArg::Arg(TypeOrExpr::Expr(e))], []) => (*e, true), + ([], [kw]) => match kw.value { + TypeOrExpr::Expr(e) => (e, false), + TypeOrExpr::Type(..) => return None, + }, + _ => return None, }; - let got = value_arg.infer(self, errors); - self.check_type_as_call_argument(&got, &want, value_arg.range(), errors, &|| { + let want = self.enum_value_lookup_expected_arg_type(cls, keywords)?; + let (expr, positional) = value_expr; + let got = self.expr_infer(expr, errors); + self.check_type_as_call_argument(&got, &want, expr.range(), errors, &|| { let mut tcc = TypeCheckContext::of_kind(TypeCheckKind::CallArgument( Some(Name::new_static("value")), None, @@ -1113,36 +1133,7 @@ impl<'ctx, 'answer, Ans: LookupAnswer> AnswersSolver<'ctx, 'answer, Ans> { } tcc }); - } - - /// `__init__` defined on the user enum (not object / enum.Enum*), used to type value lookup. - fn enum_custom_init_for_value_lookup(&self, cls: &ClassType) -> Option { - let member = - self.get_class_member_with_defining_class(cls.class_object(), &dunder::INIT)?; - let definer = &member.defining_class; - if definer.has_toplevel_qname("builtins", "object") - || definer.has_toplevel_qname(ModuleName::enum_().as_str(), "Enum") - || definer.has_toplevel_qname(ModuleName::enum_().as_str(), "IntEnum") - || definer.has_toplevel_qname(ModuleName::enum_().as_str(), "StrEnum") - || definer.has_toplevel_qname(ModuleName::enum_().as_str(), "Flag") - || definer.has_toplevel_qname(ModuleName::enum_().as_str(), "IntFlag") - || definer.has_toplevel_qname(ModuleName::enum_().as_str(), "ReprEnum") - { - return None; - } - self.get_dunder_init(cls, false) - } - - fn enum_value_lookup_expected_arg_type(&self, cls: &ClassType) -> Option { - let metadata = self.get_metadata_for_class(cls.class_object()); - let enum_metadata = metadata.enum_metadata()?; - let value_ty = self.enum_value_lookup_on_class(cls, enum_metadata); - // Member literals (`Literal["a", "b"]`) describe which values exist, but - // Enum(x) accepts any value of that base type (`str`) and raises at - // runtime when the value is missing. Promote so `cls(val: str)` stays - // valid. Also accept an already-constructed member (`cls(cls.A)`). - let promoted = value_ty.promote_implicit_literals(self.stdlib); - Some(self.unions(vec![promoted, Type::ClassType(cls.clone())])) + Some((got, expr.range(), positional)) } /// If the metaclass defines a custom `__call__`, call it. If the `__call__` comes from `type`, ignore @@ -1380,6 +1371,30 @@ impl<'ctx, 'answer, Ans: LookupAnswer> AnswersSolver<'ctx, 'answer, Ans> { let errors = self.error_collector(); // The solutions the constructor call settled on. let mut ctor_table = None; + // For enum value lookup, infer the lookup argument once, check it, and hand the + // inferred type (not the expression) to the metaclass `__call__` check. + let enum_lookup = if class_metadata.is_enum() { + self.check_enum_value_lookup_arg(&cls, args, keywords, &errors, context) + } else { + None + }; + let enum_args; + let enum_keywords; + let (args, keywords) = match &enum_lookup { + Some((ty, range, true)) => { + enum_args = [CallArg::Arg(TypeOrExpr::Type(ty, *range))]; + (&enum_args[..], keywords) + } + Some((ty, range, false)) => { + enum_keywords = [CallKeyword { + range: keywords[0].range, + arg: keywords[0].arg, + value: TypeOrExpr::Type(ty, *range), + }]; + (args, &enum_keywords[..]) + } + None => (args, keywords), + }; if let Some(ret) = self.call_metaclass( &cls, arguments_range, @@ -1406,16 +1421,7 @@ impl<'ctx, 'answer, Ans: LookupAnswer> AnswersSolver<'ctx, 'answer, Ans> { // Enum construction is routed through EnumMeta.__call__, which performs // member lookup by value. A custom enum __new__ is used for member creation // during class definition and should not be re-applied at call sites. - // EnumMeta types `value` as Any; refine against __init__ / member value types. if class_metadata.is_enum() { - self.check_enum_value_lookup_call( - &cls, - args, - keywords, - arguments_range, - &errors, - context, - ); let ty = if constructor_kind == ConstructorKind::TypeOfSelf { self.heap.mk_self_type(cls) } else { diff --git a/pyrefly/lib/test/enums.rs b/pyrefly/lib/test/enums.rs index 4dc75fb115..55b3569d42 100644 --- a/pyrefly/lib/test/enums.rs +++ b/pyrefly/lib/test/enums.rs @@ -1395,49 +1395,153 @@ assert_type(x, Literal["q"]) ); testcase!( - test_enum_call_rejects_incompatible_value, + test_enum_call_rejects_none_for_int_members, r#" from enum import Enum -from typing import TYPE_CHECKING, Self -class Category(Enum): - if TYPE_CHECKING: - @property - def value(self) -> str: ... +class Color(Enum): + RED = 1 + GREEN = 2 - def __init__(self, value: str) -> None: - self._id: str - else: - def __init__(self, value: str, id: str) -> None: - self._value_ = value - self._id = id +Color(1) +Color(value=2) +Color(Color.RED) +Color(None) # E: Argument `None` is not assignable to parameter `value` with type `Color | complex | float | int` +Color(value=None) # E: Argument `None` is not assignable to parameter `value` with type `Color | complex | float | int` + "#, +); +testcase!( + test_enum_call_cross_numeric_equality, + r#" +from enum import Enum + +class Color(Enum): + RED = 1 + GREEN = 2 + +# 1.0 == 1, so lookup succeeds at runtime. +Color(1.0) +Color(True) + "#, +); + +testcase!( + test_enum_call_ignores_multi_param_init, + r#" +from enum import Enum + +class Planet(Enum): + MERCURY = (3.303e+23, 2.4397e6) + EARTH = (5.976e+24, 6.37814e6) + + def __init__(self, mass: float, radius: float) -> None: + self.mass = mass + self.radius = radius + +# `__init__` runs at class definition, not at lookup. +Planet((5.976e+24, 6.37814e6)) +Planet(Planet.EARTH) +Planet(value=(3.303e+23, 2.4397e6)) + "#, +); + +testcase!( + test_enum_call_init_param_names_irrelevant, + r#" +from enum import Enum + +class Category(Enum): GAMES = "GAMES", "0_0" MUSIC = "MUSIC", "1_1" - @property - def id(self) -> str: - return self._id + def __init__(self, label: str, id: str) -> None: + self._value_ = label + self.id = id + +Category(Category.GAMES) +Category(value=("GAMES", "0_0")) + "#, +); + +testcase!( + test_enum_call_new_changes_value_type, + r#" +from enum import Enum + +class Coordinate(Enum): + def __new__(cls, value: int, label: str): + obj = object.__new__(cls) + obj._value_ = label + return obj + + PX = (0, "P.X") + PY = (1, "P.Y") + +# `_value_` is the label string, not the `(int, str)` creation arguments. +Coordinate("P.X") +Coordinate(Coordinate.PX) + +class IntCoordinate(int, Enum): + def __new__(cls, value: int, label: str): + obj = int.__new__(cls, value) + obj._value_ = value + return obj + + PX = (0, "P.X") + +IntCoordinate(0) + +class Untyped(Enum): + def __new__(cls, raw: str): + obj = object.__new__(cls) + obj._value_ = len(raw) + return obj + + A = "a" + BB = "bb" + +Untyped(1) +Untyped("anything") + "#, +); + +testcase!( + test_enum_call_custom_metaclass_and_missing, + r#" +from enum import Enum, EnumMeta +from typing import Any, Self + +class LenientMeta(EnumMeta): + def __call__(cls, value: Any, *args: Any, **kwargs: Any) -> Any: + return super().__call__(value, *args, **kwargs) + +class WithMeta(Enum, metaclass=LenientMeta): + A = 1 + +WithMeta(None) +WithMeta("a") + +class WithMissing(Enum): + A = 1 @classmethod def _missing_(cls, value: object) -> Self: - raise ValueError(value) + return cls.A -Category("GAMES") -Category(None) # E: Argument `None` is not assignable to parameter `value` with type `str` +WithMissing(None) +WithMissing("a") "#, ); testcase!( - test_enum_call_rejects_none_for_int_members, + test_enum_call_deeply_nested, r#" from enum import Enum class Color(Enum): RED = 1 - GREEN = 2 -Color(1) -Color(None) # E: Argument `None` is not assignable to parameter `value` with type `Color | int` +Color(Color(Color(Color(Color(Color(Color(Color(Color(Color(Color(Color(Color(Color(Color(Color(Color(Color(Color(Color(Color(1))))))))))))))))))))) "#, ); From 1d51864f39a7764c05882ea760d8245158d6fb10 Mon Sep 17 00:00:00 2001 From: Mangesh Raut Date: Sun, 1 Nov 2026 15:42:07 +0000 Subject: [PATCH 5/7] enums: skip lookup check when stub values are ellipsis placeholders --- pyrefly/lib/alt/call.rs | 5 ++++- pyrefly/lib/test/enums.rs | 25 +++++++++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/pyrefly/lib/alt/call.rs b/pyrefly/lib/alt/call.rs index 7ce0fd7fb2..534f7265b4 100644 --- a/pyrefly/lib/alt/call.rs +++ b/pyrefly/lib/alt/call.rs @@ -1169,7 +1169,10 @@ impl<'ctx, 'answer, Ans: LookupAnswer> AnswersSolver<'ctx, 'answer, Ans> { return None; } let value_ty = self.enum_value_lookup_on_class(cls, enum_metadata); - if value_ty.is_any() { + // Stub enums often write `X = ...`; that is a placeholder, not the real value type. + let mut has_ellipsis = false; + value_ty.universe(&mut |t| has_ellipsis |= t.is_ellipsis_value()); + if value_ty.is_any() || has_ellipsis { return None; } // Member literals say which values exist, but lookup accepts any value of the base diff --git a/pyrefly/lib/test/enums.rs b/pyrefly/lib/test/enums.rs index 0684e76bcf..496451c840 100644 --- a/pyrefly/lib/test/enums.rs +++ b/pyrefly/lib/test/enums.rs @@ -1564,3 +1564,28 @@ class Format(Enum): return formatter(value) "#, ); + +fn env_enum_stub_ellipsis() -> TestEnv { + let mut env = TestEnv::new(); + env.add_with_path( + "res", + "res.pyi", + r#" +from enum import Enum + +class Resolution(Enum): + RESO_NS = ... + RESO_US = ... +"#, + ); + env +} + +testcase!( + test_enum_lookup_stub_ellipsis_values_unchecked, + env_enum_stub_ellipsis(), + r#" +import res +res.Resolution(7) +"#, +); From c0eed6bd9b1ac1222000a3a89c3662da61a48821 Mon Sep 17 00:00:00 2001 From: Mangesh Raut Date: Mon, 2 Nov 2026 17:04:25 +0530 Subject: [PATCH 6/7] enums: refine lookup argument handling and equality compatibility --- pyrefly/lib/alt/call.rs | 189 +++++++++++++++++++++++++++------ pyrefly/lib/alt/class/enums.rs | 23 ++-- pyrefly/lib/test/enums.rs | 99 ++++++++++++++++- 3 files changed, 269 insertions(+), 42 deletions(-) diff --git a/pyrefly/lib/alt/call.rs b/pyrefly/lib/alt/call.rs index 5d156d2e10..00af55aa8b 100644 --- a/pyrefly/lib/alt/call.rs +++ b/pyrefly/lib/alt/call.rs @@ -1133,8 +1133,16 @@ impl<'ctx, 'answer, Ans: LookupAnswer> AnswersSolver<'ctx, 'answer, Ans> { fn enum_value_lookup_expected_arg_type( &self, cls: &ClassType, + constructor_kind: &ConstructorKind, keywords: &[CallKeyword], ) -> Option { + // Do not restrict lookup on `type[Self]` calls, or on extensible memberless enum bases + // where subclasses can define members or dynamic lookup contracts like `_missing_`. + if *constructor_kind == ConstructorKind::TypeOfSelf + || self.get_enum_members(cls.class_object()).is_empty() + { + return None; + } if keywords .iter() .any(|kw| kw.arg.is_none_or(|id| id.as_str() != "value")) @@ -1175,45 +1183,110 @@ impl<'ctx, 'answer, Ans: LookupAnswer> AnswersSolver<'ctx, 'answer, Ans> { if value_ty.is_any() || has_ellipsis { return None; } + // For containers (tuples, lists, etc.), cross-numeric equality across nested elements + // makes strict nominal lookup checking unsound without complex contextual inference. + // Bail out conservatively rather than rejecting equality-compatible container lookups. + let mut has_container = false; + value_ty.universe(&mut |t| { + has_container |= matches!(t, Type::Tuple(..)) + || matches!(t, Type::ClassType(c) if { + let obj = c.class_object(); + obj == self.stdlib.tuple_object() + || obj == self.stdlib.list_object() + || obj == self.stdlib.set_object() + || obj == self.stdlib.frozenset_object() + || obj == self.stdlib.dict_object() + }); + }); + if has_container { + return None; + } // Member literals say which values exist, but lookup accepts any value of the base // type and raises at runtime when it is missing, so promote literals. let promoted = value_ty.promote_implicit_literals(self.stdlib); let mut options = vec![promoted.clone(), Type::ClassType(cls.clone())]; // Lookup uses equality, so numeric values compare equal across int/float/complex - // (`Color(1.0)` finds a member whose value is `1`). + // (`Color(1.0)` finds a member whose value is `1`, `B(1)` finds `True`). let int = self.heap.mk_class_type(self.stdlib.int().clone()); let float = self.heap.mk_class_type(self.stdlib.float().clone()); - if self.is_subset_eq(&int, &promoted) || self.is_subset_eq(&float, &promoted) { + let complex = self.heap.mk_class_type(self.stdlib.complex().clone()); + let is_numeric = self.is_subset_eq(&promoted, &int) + || self.is_subset_eq(&int, &promoted) + || self.is_subset_eq(&promoted, &float) + || self.is_subset_eq(&float, &promoted) + || self.is_subset_eq(&promoted, &complex) + || self.is_subset_eq(&complex, &promoted); + if is_numeric { + options.push(int); options.push(float); - options.push(self.heap.mk_class_type(self.stdlib.complex().clone())); + options.push(complex); } Some(self.unions(options)) } - /// Check the value-lookup argument of an enum call against the expected lookup type. - /// Returns rewritten args/keywords that carry the already-inferred argument type, so the - /// metaclass `__call__` check does not infer the argument expression a second time - /// (re-inferring is exponential for nested enum calls). - fn check_enum_value_lookup_arg<'b>( + fn is_numeric_equality_compatible(&self, ty: &Type) -> bool { + match ty { + Type::None + | Type::LiteralString(..) + | Type::Tuple(..) + | Type::TypedDict(..) + | Type::PartialTypedDict(..) => false, + Type::Literal(lit) => match &lit.value { + Lit::Str(..) | Lit::Bytes(..) => false, + Lit::Int(..) | Lit::Bool(..) => true, + Lit::Enum(..) => false, + }, + Type::ClassType(cls) => { + let obj = cls.class_object(); + if obj == self.stdlib.str().class_object() + || obj == self.stdlib.bytes().class_object() + || obj == self.stdlib.bytearray().class_object() + || obj == self.stdlib.tuple_object() + || obj == self.stdlib.list_object() + || obj == self.stdlib.set_object() + || obj == self.stdlib.frozenset_object() + || obj == self.stdlib.dict_object() + || obj == self.stdlib.mapping_object() + || obj == self.stdlib.none_type().class_object() + { + return false; + } + if self.get_metadata_for_class(obj).is_enum() + && !self.is_subset_eq(ty, &self.heap.mk_class_type(self.stdlib.int().clone())) + { + return false; + } + true + } + Type::Union(u) => u + .members + .iter() + .all(|t| self.is_numeric_equality_compatible(t)), + Type::Intersect(inner) => { + let (tys, _) = inner.as_ref(); + tys.iter().any(|t| self.is_numeric_equality_compatible(t)) + } + _ => false, + } + } + + fn check_enum_lookup_arg_compatibility( &self, - cls: &ClassType, - args: &[CallArg<'b>], - keywords: &[CallKeyword<'b>], + got: &Type, + want: &Type, + range: TextRange, errors: &ErrorCollector, context: Option<&dyn Fn() -> ErrorContext>, - ) -> Option<(Type, TextRange, bool)> { - let value_expr = match (args, keywords) { - ([CallArg::Arg(TypeOrExpr::Expr(e))], []) => (*e, true), - ([], [kw]) => match kw.value { - TypeOrExpr::Expr(e) => (e, false), - TypeOrExpr::Type(..) => return None, - }, - _ => return None, - }; - let want = self.enum_value_lookup_expected_arg_type(cls, keywords)?; - let (expr, positional) = value_expr; - let got = self.expr_infer(expr, errors); - self.check_type_as_call_argument(&got, &want, expr.range(), errors, &|| { + ) { + if self.is_subset_eq(got, want) { + return; + } + let int = self.heap.mk_class_type(self.stdlib.int().clone()); + let is_numeric_target = self.is_subset_eq(&int, want); + if is_numeric_target && self.is_numeric_equality_compatible(got) { + return; + } + self.check_type_as_call_argument(got, want, range, errors, &|| { let mut tcc = TypeCheckContext::of_kind(TypeCheckKind::CallArgument( Some(Name::new_static("value")), None, @@ -1223,7 +1296,36 @@ impl<'ctx, 'answer, Ans: LookupAnswer> AnswersSolver<'ctx, 'answer, Ans> { } tcc }); - Some((got, expr.range(), positional)) + } + + /// Check the value-lookup argument of an enum call against the expected lookup type. + /// Accepts both expressions and already-flattened types; expressions are inferred into + /// `arg_errors` so errors do not trigger retry without a hint. Returns rewritten + /// args/keywords carrying the inferred type so metaclass checking avoids re-inference. + fn check_enum_value_lookup_arg<'b>( + &self, + cls: &ClassType, + constructor_kind: &ConstructorKind, + args: &[CallArg<'b>], + keywords: &[CallKeyword<'b>], + errors: &ErrorCollector, + arg_errors: &ErrorCollector, + context: Option<&dyn Fn() -> ErrorContext>, + ) -> Option<(Type, TextRange, bool)> { + let (value_arg, positional) = match (args, keywords) { + ([CallArg::Arg(arg)], []) => (arg, true), + ([], [kw]) if kw.arg.as_ref().is_some_and(|id| id.as_str() == "value") => { + (&kw.value, false) + } + _ => return None, + }; + let want = self.enum_value_lookup_expected_arg_type(cls, constructor_kind, keywords)?; + let (got, range) = match value_arg { + TypeOrExpr::Expr(e) => (self.expr_infer(e, arg_errors), e.range()), + TypeOrExpr::Type(ty, r) => ((*ty).clone(), *r), + }; + self.check_enum_lookup_arg_compatibility(&got, &want, range, errors, context); + Some((got, range, positional)) } /// If the metaclass defines a custom `__call__`, call it. If the `__call__` comes from `type`, ignore @@ -1387,6 +1489,7 @@ impl<'ctx, 'answer, Ans: LookupAnswer> AnswersSolver<'ctx, 'answer, Ans> { context: Option<&dyn Fn() -> ErrorContext>, hint: Option, ) -> Type { + let class_metadata = self.get_metadata_for_class(cls.class_object()); // Classes that override `__new__` and also define `__init__` check/infer args // twice, potentially leading to exponential check costs: `O(2^depth)`. let checks_args_twice = |cls: &ClassType, preserve_self| { @@ -1405,19 +1508,33 @@ impl<'ctx, 'answer, Ans: LookupAnswer> AnswersSolver<'ctx, 'answer, Ans> { }) .chain(keywords.iter().map(|k| &k.value)) .any(is_deeply_nested) - && checks_args_twice(&cls, constructor_kind == ConstructorKind::TypeOfSelf); + && (checks_args_twice(&cls, constructor_kind == ConstructorKind::TypeOfSelf) + || class_metadata.is_enum()); + + let flatten_enum = class_metadata.is_enum() + && match (args, keywords) { + ([CallArg::Arg(TypeOrExpr::Expr(_))], []) => true, + ([], [kw]) => { + kw.arg.as_ref().is_some_and(|id| id.as_str() == "value") + && matches!(kw.value, TypeOrExpr::Expr(_)) + } + _ => false, + }; + let flatten = flatten_nested || flatten_enum; let call = CallWithTypes::new(); - let flattened = flatten_nested.then(|| { + let flattened = flatten.then(|| { ( args.map(|a| match a { - CallArg::Arg(x) | CallArg::Star(x, _) if is_deeply_nested(x) => { + CallArg::Arg(x) | CallArg::Star(x, _) + if (flatten_nested && is_deeply_nested(x)) || flatten_enum => + { call.call_arg(a, self, arg_errors) } _ => a.clone(), }), keywords.map(|k| { - if is_deeply_nested(&k.value) { + if (flatten_nested && is_deeply_nested(&k.value)) || flatten_enum { call.call_keyword(k, self, arg_errors) } else { k.clone() @@ -1495,10 +1612,18 @@ impl<'ctx, 'answer, Ans: LookupAnswer> AnswersSolver<'ctx, 'answer, Ans> { let return_errors = self.error_collector(); // The solutions the constructor call settled on. let mut ctor_table = None; - // For enum value lookup, infer the lookup argument once, check it, and hand the - // inferred type (not the expression) to the metaclass `__call__` check. + // For enum value lookup, check the lookup argument and hand the inferred type + // (not the expression) to the metaclass `__call__` check. let enum_lookup = if class_metadata.is_enum() { - self.check_enum_value_lookup_arg(&cls, args, keywords, &errors, context) + self.check_enum_value_lookup_arg( + &cls, + &constructor_kind, + args, + keywords, + &errors, + &arg_errors, + context, + ) } else { None }; diff --git a/pyrefly/lib/alt/class/enums.rs b/pyrefly/lib/alt/class/enums.rs index 247a1390da..c19dd881a1 100644 --- a/pyrefly/lib/alt/class/enums.rs +++ b/pyrefly/lib/alt/class/enums.rs @@ -341,9 +341,9 @@ impl<'ctx, 'answer, Ans: LookupAnswer> AnswersSolver<'ctx, 'answer, Ans> { /// If this enum mixes in a data type by inheriting from it, return the mixed-in type. /// Searches all bases, not just the first, to handle cases like /// `IntegerChoices(Choices, IntEnum)` where the data type comes from `IntEnum`. - /// A non-enum base is only treated as a data type if it has `__new__` + /// A non-enum base is treated as a data type if it is a dataclass or has `__new__` /// inherited from a class other than `object`, which is how Python - /// distinguishes data type mixins (`int`, `str`, `float`, and their + /// distinguishes data type mixins (`int`, `str`, `float`, dataclasses, and their /// subclasses like `MyStr(str)`) from regular method mixins. fn mixed_in_enum_data_type(&self, class: &Class) -> Option { let bases = self.get_base_types_for_class(class); @@ -356,13 +356,18 @@ impl<'ctx, 'answer, Ans: LookupAnswer> AnswersSolver<'ctx, 'answer, Ans> { return Some(ty); } } else { - let is_data_type = self - .get_class_member_with_defining_class(base.class_object(), &dunder::NEW) - .is_some_and(|field| { - !field - .defining_class - .has_toplevel_qname("builtins", "object") - }); + let is_dataclass = self + .get_metadata_for_class(base.class_object()) + .dataclass_metadata() + .is_some(); + let is_data_type = is_dataclass + || self + .get_class_member_with_defining_class(base.class_object(), &dunder::NEW) + .is_some_and(|field| { + !field + .defining_class + .has_toplevel_qname("builtins", "object") + }); if is_data_type { return Some(self.heap.mk_class_type(base.clone())); } diff --git a/pyrefly/lib/test/enums.rs b/pyrefly/lib/test/enums.rs index 496451c840..7f488f3194 100644 --- a/pyrefly/lib/test/enums.rs +++ b/pyrefly/lib/test/enums.rs @@ -1414,15 +1414,37 @@ Color(value=None) # E: Argument `None` is not assignable to parameter `value` w testcase!( test_enum_call_cross_numeric_equality, r#" +from decimal import Decimal from enum import Enum +from fractions import Fraction class Color(Enum): RED = 1 GREEN = 2 -# 1.0 == 1, so lookup succeeds at runtime. +# Numeric values and equality domains succeed at runtime. Color(1.0) Color(True) +Color(Decimal(1)) +Color(Fraction(1)) + +class B(Enum): + YES = True + +B(1) +B(1.0) +B(1 + 0j) +B(None) # E: Argument `None` is not assignable to parameter `value` with type `B | bool | complex | float | int` + +class TupleEnum(Enum): + PAIR = (1, 2) + +TupleEnum((1.0, 2.0)) + +class ListEnum(Enum): + ITEMS = [1, 2] + +ListEnum([1.0, 2.0]) "#, ); @@ -1546,6 +1568,81 @@ Color(Color(Color(Color(Color(Color(Color(Color(Color(Color(Color(Color(Color(Co "#, ); +testcase!( + test_enum_call_deeply_nested_error_linear_time, + r#" +from enum import Enum + +class E(Enum): + A = 1 + +def identity[T](x: T) -> T: + return x + +identity(E(identity(E(identity(E(identity(E(identity(E(identity(E(identity(E(identity(E(identity(E(identity(E(identity(E(identity(E(1 + "x")))))))))))))))))))))))) # E: `+` is not supported between `Literal[1]` and `Literal['x']` + "#, +); + +testcase!( + test_enum_call_extensible_base_self_type, + r#" +from enum import Enum +from typing import Self + +class Base(str, Enum): + @classmethod + def parse(cls, raw: object) -> Self: + return cls(raw) + +class Color(Base): + RED = "red" + + @classmethod + def _missing_(cls, value: object) -> "Color": + return cls.RED + +assert Color.parse(None) is Color.RED + "#, +); + +testcase!( + test_enum_call_dataclass_mixin, + r#" +from dataclasses import dataclass +from enum import Enum + +@dataclass +class Data: + n: int + +class E(Data, Enum): + A = (1,) + +assert E(Data(1)) is E.A + "#, +); + +testcase!( + test_enum_call_pre_inferred_arguments, + r#" +from enum import Enum + +class Color(Enum): + RED = 1 + + def __init__(self, value: int) -> None: + self._value_ = value + +def identity[T](x: T) -> T: + return x + +Color(None) # E: Argument `None` is not assignable to parameter `value` with type `Color | complex | float | int` +Color(identity(None)) # E: Argument `None` is not assignable to parameter `value` with type `Color | complex | float | int` +Color(identity(identity(identity(identity(None))))) # E: Argument `None` is not assignable to parameter `value` with type `Color | complex | float | int` +Color(value=identity(identity(identity(identity(None))))) # E: Argument `None` is not assignable to parameter `value` with type `Color | complex | float | int` + "#, +); + // https://github.com/facebook/pyrefly/issues/5279 testcase!( test_enum_member_keyword_value, From 189e3b3fc36ee99b3c8d4ac7dcd21643a793f88f Mon Sep 17 00:00:00 2001 From: Mangesh Raut Date: Tue, 3 Nov 2026 01:59:21 +0530 Subject: [PATCH 7/7] enums: accept object lookup arguments and verify 20-deep nested errors --- pyrefly/lib/alt/call.rs | 35 ++++++++++++++++++++++++------ pyrefly/lib/test/enums.rs | 45 ++++++++++++++++++++++++++++++++++++++- 2 files changed, 73 insertions(+), 7 deletions(-) diff --git a/pyrefly/lib/alt/call.rs b/pyrefly/lib/alt/call.rs index 00af55aa8b..7273a2a993 100644 --- a/pyrefly/lib/alt/call.rs +++ b/pyrefly/lib/alt/call.rs @@ -1248,6 +1248,7 @@ impl<'ctx, 'answer, Ans: LookupAnswer> AnswersSolver<'ctx, 'answer, Ans> { || obj == self.stdlib.dict_object() || obj == self.stdlib.mapping_object() || obj == self.stdlib.none_type().class_object() + || obj == self.stdlib.object().class_object() { return false; } @@ -1270,6 +1271,33 @@ impl<'ctx, 'answer, Ans: LookupAnswer> AnswersSolver<'ctx, 'answer, Ans> { } } + fn is_enum_lookup_compatible(&self, got: &Type, want: &Type) -> bool { + if self.is_subset_eq(got, want) { + return true; + } + // Arguments typed `object` or `Any` are accepted because enum lookup + // performs equality comparison on arbitrary objects at runtime. + if got.is_object() || got.is_any() { + return true; + } + let int = self.heap.mk_class_type(self.stdlib.int().clone()); + let is_numeric_target = self.is_subset_eq(&int, want); + if is_numeric_target && self.is_numeric_equality_compatible(got) { + return true; + } + match got { + Type::Union(u) => u + .members + .iter() + .all(|t| self.is_enum_lookup_compatible(t, want)), + Type::Intersect(inner) => { + let (tys, _) = inner.as_ref(); + tys.iter().any(|t| self.is_enum_lookup_compatible(t, want)) + } + _ => false, + } + } + fn check_enum_lookup_arg_compatibility( &self, got: &Type, @@ -1278,12 +1306,7 @@ impl<'ctx, 'answer, Ans: LookupAnswer> AnswersSolver<'ctx, 'answer, Ans> { errors: &ErrorCollector, context: Option<&dyn Fn() -> ErrorContext>, ) { - if self.is_subset_eq(got, want) { - return; - } - let int = self.heap.mk_class_type(self.stdlib.int().clone()); - let is_numeric_target = self.is_subset_eq(&int, want); - if is_numeric_target && self.is_numeric_equality_compatible(got) { + if self.is_enum_lookup_compatible(got, want) { return; } self.check_type_as_call_argument(got, want, range, errors, &|| { diff --git a/pyrefly/lib/test/enums.rs b/pyrefly/lib/test/enums.rs index 7f488f3194..4efa8b542b 100644 --- a/pyrefly/lib/test/enums.rs +++ b/pyrefly/lib/test/enums.rs @@ -1579,7 +1579,7 @@ class E(Enum): def identity[T](x: T) -> T: return x -identity(E(identity(E(identity(E(identity(E(identity(E(identity(E(identity(E(identity(E(identity(E(identity(E(identity(E(identity(E(1 + "x")))))))))))))))))))))))) # E: `+` is not supported between `Literal[1]` and `Literal['x']` +identity(E(identity(E(identity(E(identity(E(identity(E(identity(E(identity(E(identity(E(identity(E(identity(E(identity(E(identity(E(identity(E(identity(E(identity(E(identity(E(identity(E(identity(E(identity(E(identity(E(1 + "x")))))))))))))))))))))))))))))))))))))))) # E: `+` is not supported between `Literal[1]` and `Literal['x']` "#, ); @@ -1686,3 +1686,46 @@ import res res.Resolution(7) "#, ); + +testcase!( + test_enum_call_object_and_union_arguments, + r#" +from enum import Enum +from typing import Any + +class IntColor(Enum): + RED = 1 + GREEN = 2 + +class StrColor(Enum): + HOSTED = "hosted" + LOCAL = "local" + +def test_object_and_any( + obj: object, + any_val: Any, + opt_obj: object | None, + opt_str: str | None, + zulip_union: bool | int | str, + meson_union: bool | int | list[str] | str, +) -> None: + # `object` and `Any` are accepted for both numeric and string enums because + # lookup uses equality at runtime. + IntColor(obj) + IntColor(any_val) + StrColor(obj) + StrColor(any_val) + + # Nullable types must be rejected unless the enum accepts None or defines _missing_. + IntColor(opt_obj) # E: Argument `object | None` is not assignable to parameter `value` with type `IntColor | complex | float | int` + StrColor(opt_obj) # E: Argument `object | None` is not assignable to parameter `value` with type `StrColor | str` + StrColor(opt_str) # E: Argument `str | None` is not assignable to parameter `value` with type `StrColor | str` + + # Incompatible unions where some branches do not match the target must be rejected. + IntColor(zulip_union) # E: Argument `bool | int | str` is not assignable to parameter `value` with type `IntColor | complex | float | int` + StrColor(meson_union) # E: Argument `bool | int | list[str] | str` is not assignable to parameter `value` with type `StrColor | str` + + # Disjoint type (str passed to int enum) must be rejected. + IntColor("1") # E: Argument `Literal['1']` is not assignable to parameter `value` with type `IntColor | complex | float | int` +"#, +);