From bd252e707dd8ef508a0f2d46fdbd85f647e8f473 Mon Sep 17 00:00:00 2001 From: Asuka Minato Date: Fri, 6 Mar 2026 08:21:10 +0900 Subject: [PATCH 1/4] fix --- crates/pyrefly_types/src/stdlib.rs | 7 +++++++ pyrefly/lib/alt/operators.rs | 22 ++++++++++++++++++++-- pyrefly/lib/test/operators.rs | 28 ++++++++++++++++++++++++++++ 3 files changed, 55 insertions(+), 2 deletions(-) diff --git a/crates/pyrefly_types/src/stdlib.rs b/crates/pyrefly_types/src/stdlib.rs index ccbe91c942..26efd9ce83 100644 --- a/crates/pyrefly_types/src/stdlib.rs +++ b/crates/pyrefly_types/src/stdlib.rs @@ -115,6 +115,8 @@ pub struct Stdlib { builtins_type: StdlibResult, /// At runtime, `types.EllipsisType` is new in 3.10, but typeshed defines it unconditionally. ellipsis_type: StdlibResult, + /// At runtime, `types.NotImplementedType` is new in 3.10, but typeshed defines it unconditionally. + not_implemented_type: StdlibResult, /// Moved from `_typeshed` to `types` in 3.10. none_type: StdlibResult, function_type: StdlibResult, @@ -266,6 +268,7 @@ impl Stdlib { enumerate: lookup_generic(builtins, "enumerate", 1), builtins_type: lookup_concrete(builtins, "type"), ellipsis_type: lookup_concrete(types, "EllipsisType"), + not_implemented_type: lookup_concrete(types, "NotImplementedType"), none_type: lookup_concrete(none_location, "NoneType"), iterable: lookup_generic(typing, "Iterable", 1), iterator: lookup_generic(typing, "Iterator", 1), @@ -384,6 +387,10 @@ impl Stdlib { Self::primitive(&self.ellipsis_type) } + pub fn not_implemented_type(&self) -> &ClassType { + Self::primitive(&self.not_implemented_type) + } + pub fn none_type(&self) -> &ClassType { Self::primitive(&self.none_type) } diff --git a/pyrefly/lib/alt/operators.rs b/pyrefly/lib/alt/operators.rs index 897b57b2a3..73100d1194 100644 --- a/pyrefly/lib/alt/operators.rs +++ b/pyrefly/lib/alt/operators.rs @@ -233,6 +233,8 @@ impl<'ctx, 'answer, Ans: LookupAnswer> AnswersSolver<'ctx, 'answer, Ans> { context: &dyn Fn() -> ErrorContext, ) -> Type { let mut first_call = None; + let mut successful_ret = self.heap.mk_never(); + let not_implemented_type = self.stdlib.not_implemented_type(); for (dunder, target, arg) in calls { let method_type_dunder = self.type_of_magic_dunder_attr( target, @@ -261,13 +263,29 @@ impl<'ctx, 'answer, Ans: LookupAnswer> AnswersSolver<'ctx, 'answer, Ans> { // Soft errors (e.g. unknown-argument-type) must not reject an otherwise // valid dunder call, so gate on hard errors only. if !call_errors.has_hard() { + let ret_without_not_implemented = self.distribute_over_union(&ret, |ret| { + if matches!(ret, Type::ClassType(cls) if cls == not_implemented_type) { + self.heap.mk_never() + } else { + ret.clone() + } + }); + if ret_without_not_implemented.is_never() { + continue; + } errors.extend(callee_errors); - return ret; + if ret_without_not_implemented != ret { + successful_ret = self.union(successful_ret, ret_without_not_implemented); + continue; + } + return self.union(successful_ret, ret_without_not_implemented); } else if first_call.is_none() { first_call = Some((callee_errors, call_errors, ret)); } } - if let Some((callee_errors, call_errors, ret)) = first_call { + if !successful_ret.is_never() { + successful_ret + } else if let Some((callee_errors, call_errors, ret)) = first_call { errors.extend(callee_errors); errors.extend(call_errors); ret diff --git a/pyrefly/lib/test/operators.rs b/pyrefly/lib/test/operators.rs index 9b32a6b50b..211be38c2e 100644 --- a/pyrefly/lib/test/operators.rs +++ b/pyrefly/lib/test/operators.rs @@ -706,6 +706,34 @@ assert_type(a, A) "#, ); +testcase!( + test_binop_not_implemented_type_uses_runtime_fallback, + r#" +from types import NotImplementedType +from typing import assert_type + +class A: + def __add__(self, other: object) -> NotImplementedType: + return NotImplemented + +class B: + def __radd__(self, other: A) -> int: + return 1 + +assert_type(A() + B(), int) + +class C: + def __add__(self, other: object) -> int | NotImplementedType: + return 1 if isinstance(other, C) else NotImplemented + +class D: + def __radd__(self, other: C) -> str: + return "" + +assert_type(C() + D(), int | str) + "#, +); + // We try __iadd__ and some fallback dunders. When all fail, the least confusing option is to use __iadd__. testcase!( test_iadd_error, From 365027f6c8d519238edafeabda0d3e9a07a48283 Mon Sep 17 00:00:00 2001 From: Asuka Minato Date: Fri, 31 Jul 2026 00:05:26 +0900 Subject: [PATCH 2/4] comment --- pyrefly/lib/alt/operators.rs | 13 ++++--------- pyrefly/lib/test/operators.rs | 22 ++++++++++++++++++++-- 2 files changed, 24 insertions(+), 11 deletions(-) diff --git a/pyrefly/lib/alt/operators.rs b/pyrefly/lib/alt/operators.rs index 73100d1194..a5c057f548 100644 --- a/pyrefly/lib/alt/operators.rs +++ b/pyrefly/lib/alt/operators.rs @@ -225,6 +225,8 @@ impl<'ctx, 'answer, Ans: LookupAnswer> AnswersSolver<'ctx, 'answer, Ans> { Some(result_ty) } + /// Operands must be distributed over unions before calling this method, so only a union + /// member whose result is entirely `NotImplementedType` advances to the reflected dunder. fn try_binop_calls( &self, calls: &[(&Name, &Type, &Type)], @@ -233,7 +235,6 @@ impl<'ctx, 'answer, Ans: LookupAnswer> AnswersSolver<'ctx, 'answer, Ans> { context: &dyn Fn() -> ErrorContext, ) -> Type { let mut first_call = None; - let mut successful_ret = self.heap.mk_never(); let not_implemented_type = self.stdlib.not_implemented_type(); for (dunder, target, arg) in calls { let method_type_dunder = self.type_of_magic_dunder_attr( @@ -274,18 +275,12 @@ impl<'ctx, 'answer, Ans: LookupAnswer> AnswersSolver<'ctx, 'answer, Ans> { continue; } errors.extend(callee_errors); - if ret_without_not_implemented != ret { - successful_ret = self.union(successful_ret, ret_without_not_implemented); - continue; - } - return self.union(successful_ret, ret_without_not_implemented); + return ret_without_not_implemented; } else if first_call.is_none() { first_call = Some((callee_errors, call_errors, ret)); } } - if !successful_ret.is_never() { - successful_ret - } else if let Some((callee_errors, call_errors, ret)) = first_call { + if let Some((callee_errors, call_errors, ret)) = first_call { errors.extend(callee_errors); errors.extend(call_errors); ret diff --git a/pyrefly/lib/test/operators.rs b/pyrefly/lib/test/operators.rs index 211be38c2e..5b9d212298 100644 --- a/pyrefly/lib/test/operators.rs +++ b/pyrefly/lib/test/operators.rs @@ -710,7 +710,7 @@ testcase!( test_binop_not_implemented_type_uses_runtime_fallback, r#" from types import NotImplementedType -from typing import assert_type +from typing import assert_type, overload class A: def __add__(self, other: object) -> NotImplementedType: @@ -730,7 +730,25 @@ class D: def __radd__(self, other: C) -> str: return "" -assert_type(C() + D(), int | str) +assert_type(C() + D(), int) + +class E: + pass + +class F: + def __radd__(self, other: "G") -> str: + return "" + +class G: + @overload + def __add__(self, other: E) -> int: ... + @overload + def __add__(self, other: F) -> NotImplementedType: ... + def __add__(self, other: E | F) -> int | NotImplementedType: + return 1 if isinstance(other, E) else NotImplemented + +def add_union(other: E | F) -> None: + assert_type(G() + other, int | str) "#, ); From cef3209a6c9974dc655893f79aa1e4f45ce940fa Mon Sep 17 00:00:00 2001 From: Asuka Minato Date: Fri, 30 Oct 2026 16:29:12 +0900 Subject: [PATCH 3/4] test --- pyrefly/lib/test/operators.rs | 47 ++++++++++++++++++++++++++++++++++- 1 file changed, 46 insertions(+), 1 deletion(-) diff --git a/pyrefly/lib/test/operators.rs b/pyrefly/lib/test/operators.rs index 5b9d212298..e48f04b057 100644 --- a/pyrefly/lib/test/operators.rs +++ b/pyrefly/lib/test/operators.rs @@ -730,7 +730,7 @@ class D: def __radd__(self, other: C) -> str: return "" -assert_type(C() + D(), int) +assert_type(C() + D(), int | str) class E: pass @@ -752,6 +752,51 @@ def add_union(other: E | F) -> None: "#, ); +testcase!( + test_binop_never_does_not_use_runtime_fallback, + r#" +from typing import Never, NoReturn, assert_type + +class A: + def __add__(self, other: object) -> Never: + raise RuntimeError + +class B: + def __radd__(self, other: A) -> str: + return "" + +class C: + pass + +class D: + def __add__(self, other: object) -> NoReturn: + raise RuntimeError + +assert_type(A() + B(), Never) +assert_type(A() + C(), Never) +assert_type(D() + C(), Never) + "#, +); + +testcase!( + test_augassign_never_does_not_use_runtime_fallback, + r#" +from typing import Never, assert_type + +class A: + def __iadd__(self, other: "B") -> Never: + raise RuntimeError + +class B: + def __radd__(self, other: A) -> str: + return "" + +def f(a: A, b: B) -> None: + a += b + assert_type(a, Never) + "#, +); + // We try __iadd__ and some fallback dunders. When all fail, the least confusing option is to use __iadd__. testcase!( test_iadd_error, From ba97ed5074831908dd7055e18f10f0405289b119 Mon Sep 17 00:00:00 2001 From: Asuka Minato Date: Fri, 30 Oct 2026 16:29:14 +0900 Subject: [PATCH 4/4] fix --- pyrefly/lib/alt/operators.rs | 39 ++++++++++++++++++++++++------------ 1 file changed, 26 insertions(+), 13 deletions(-) diff --git a/pyrefly/lib/alt/operators.rs b/pyrefly/lib/alt/operators.rs index a5c057f548..1213cc015c 100644 --- a/pyrefly/lib/alt/operators.rs +++ b/pyrefly/lib/alt/operators.rs @@ -225,8 +225,8 @@ impl<'ctx, 'answer, Ans: LookupAnswer> AnswersSolver<'ctx, 'answer, Ans> { Some(result_ty) } - /// Operands must be distributed over unions before calling this method, so only a union - /// member whose result is entirely `NotImplementedType` advances to the reflected dunder. + /// Callers distribute operands over unions. The reflected dunder receives only operand + /// members whose forward result can be `NotImplementedType`. fn try_binop_calls( &self, calls: &[(&Name, &Type, &Type)], @@ -235,6 +235,7 @@ impl<'ctx, 'answer, Ans: LookupAnswer> AnswersSolver<'ctx, 'answer, Ans> { context: &dyn Fn() -> ErrorContext, ) -> Type { let mut first_call = None; + let mut successful_ret = self.heap.mk_never(); let not_implemented_type = self.stdlib.not_implemented_type(); for (dunder, target, arg) in calls { let method_type_dunder = self.type_of_magic_dunder_attr( @@ -264,23 +265,35 @@ impl<'ctx, 'answer, Ans: LookupAnswer> AnswersSolver<'ctx, 'answer, Ans> { // Soft errors (e.g. unknown-argument-type) must not reject an otherwise // valid dunder call, so gate on hard errors only. if !call_errors.has_hard() { - let ret_without_not_implemented = self.distribute_over_union(&ret, |ret| { - if matches!(ret, Type::ClassType(cls) if cls == not_implemented_type) { - self.heap.mk_never() - } else { - ret.clone() + let ret = match ret { + Type::ClassType(cls) if cls == *not_implemented_type => continue, + Type::Union(mut union) => { + if let Some(index) = union.members.iter().position( + |member| matches!(member, Type::ClassType(cls) if cls == not_implemented_type), + ) { + union.members.remove(index); + let ret_without_not_implemented = self.unions(union.members); + errors.extend(callee_errors); + successful_ret = self.union(successful_ret, ret_without_not_implemented); + continue; + } + Type::Union(union) } - }); - if ret_without_not_implemented.is_never() { - continue; - } + ret => ret, + }; errors.extend(callee_errors); - return ret_without_not_implemented; + return if successful_ret.is_never() { + ret + } else { + self.union(successful_ret, ret) + }; } else if first_call.is_none() { first_call = Some((callee_errors, call_errors, ret)); } } - if let Some((callee_errors, call_errors, ret)) = first_call { + if !successful_ret.is_never() { + successful_ret + } else if let Some((callee_errors, call_errors, ret)) = first_call { errors.extend(callee_errors); errors.extend(call_errors); ret