From 9a580cc0655ba59a65a63ab53b0c319edda048b3 Mon Sep 17 00:00:00 2001 From: Kyle Into Date: Wed, 14 Oct 2026 14:29:53 -0700 Subject: [PATCH 1/2] Cover restriction violations under a gradual expected type in overloaded constructors Summary: Stack context: Fix overload selection errors that cause false positives in pandas-stubs. - Add two bug-marked tests for constructor calls that violate a restriction under a gradual expected type. With overloads, `Series(NA(), dtype="Int64")` under a bare `-> Series` reports `bad-specialization`, but the unhinted call picks the `NA` overload and reports nothing. Without overloads, the hinted call hides a bad argument that the unhinted call reports. - The first test is the pattern that `pd.Series(pd.NA, dtype="Int64")` hits with pandas-stubs since D115555748. The next diff fixes both tests. Differential Revision: D125293976 --- pyrefly/lib/test/generic_restrictions.rs | 45 ++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/pyrefly/lib/test/generic_restrictions.rs b/pyrefly/lib/test/generic_restrictions.rs index 7ca35c801d..523b6e7a54 100644 --- a/pyrefly/lib/test/generic_restrictions.rs +++ b/pyrefly/lib/test/generic_restrictions.rs @@ -212,6 +212,51 @@ def f() -> None: "#, ); +// Modeled on `pd.Series(pd.NA, dtype="Int64")` under a bare `-> pd.Series` annotation. +testcase!( + bug = "A restriction violation under a gradual expected type does not reject the overload", + test_gradual_expected_type_restriction_violation_rejects_overload, + r#" +from typing import Any, Self, assert_type, overload + +class NA: ... + +class Series[S: int | str]: + @overload + def __new__(cls, data: S, dtype: int) -> Self: ... + @overload + def __new__(cls, data: S, dtype: str | None = None) -> Self: ... + @overload + def __new__(cls, data: NA, dtype: str | None = None) -> Series: ... + def __new__(cls, data: Any, dtype: Any = None) -> Any: ... + +def unhinted() -> None: + assert_type(Series(NA(), dtype="Int64"), Series[Any]) + +def hinted() -> Series: + return Series(NA(), dtype="Int64") # E: `NA` is not assignable to upper bound `int | str` + "#, +); + +// Without overloads, the restriction violation is reported, and the other arguments are checked +// against the expected type's answer. +testcase!( + bug = "The expected type hides the bad argument that the unhinted call reports", + test_gradual_expected_type_restriction_violation_without_overloads, + r#" +from typing import Any + +class Pair[T: (int, str)]: + def __init__(self, left: T, right: T) -> None: ... + +def unhinted(left: int | str, right: int | str | bytes) -> None: + Pair(left, right) # E: `int | str` is not assignable to any of constraints `int`, `str` # E: Argument `bytes | int | str` is not assignable to parameter `right` + +def hinted(left: int | str, right: int | str | bytes) -> Pair[Any]: + return Pair(left, right) # E: `int | str` is not assignable to any of constraints `int`, `str` + "#, +); + testcase!( test_any_bound_attribute_access, r#" From 7f4c9351cd4da28dd6aaf4c4ff24f64ce05abc01 Mon Sep 17 00:00:00 2001 From: Kyle Into Date: Wed, 14 Oct 2026 14:35:01 -0700 Subject: [PATCH 2/2] Reject an overload that violates a restriction under a gradual expected type (#5240) Summary: Pull Request resolved: https://github.com/facebook/pyrefly/pull/5240 Stack context: Make a restriction violation under a gradual expected type reject the overload that caused it. - Problem: a constructor call with a gradual expected type, such as `return pd.Series(pd.NA, dtype="Int64")` under a bare `-> pd.Series`, reports `bad-specialization`. The same call without the annotation type checks clean. The trigger is an overloaded `__new__` or `__init__` where an earlier overload accepts the argument only through the class type parameter (`data: S1 | ...`), and a later overload accepts it directly (`data: NAType`). - Cause: since D115555748, the expected type solves `S1` to `Any`, and argument matching records a restriction violation on that answer. The constructor reports the violation only when it finishes, so overload selection does not see it. The earlier overload matches, and the call reports the violation instead of choosing the later overload. - Fix: each call that matches arguments against a restricted answer now takes the violations that it caused into its own specialization errors. Overload selection rejects the overload and continues, as it does without the expected type. - Side effect: for a constructor without overloads, the violation is now a hard error of the hinted attempt, so `construct_with_hint` discards the expected type. The call then reports the same errors as the unhinted call. This matches the intent of D115555748: an `Any` from the expected type says nothing, and argument matching checks the call normally. Reviewed By: rchen152 Differential Revision: D125293977 --- pyrefly/lib/alt/callable.rs | 8 +++++- pyrefly/lib/solver/solver.rs | 35 ++++++++++++++++++++---- pyrefly/lib/test/generic_restrictions.rs | 28 +++++++++++++++---- 3 files changed, 59 insertions(+), 12 deletions(-) diff --git a/pyrefly/lib/alt/callable.rs b/pyrefly/lib/alt/callable.rs index e180f47f49..fd259e1a80 100644 --- a/pyrefly/lib/alt/callable.rs +++ b/pyrefly/lib/alt/callable.rs @@ -2553,6 +2553,11 @@ impl<'ctx, 'answer, Ans: LookupAnswer> AnswersSolver<'ctx, 'answer, Ans> { if let Some(self_qs) = self_qs { call_boundary.defer_quantified(self_qs); } + // A violation belongs to this call, so that an overload that causes one is rejected. It + // must be taken before generalizing, which expands the answer's var away. + let restriction_errors = ctor_targs.as_ref().map_or_else(Vec::new, |targs| { + self.solver().take_restricted_answer_errors(targs) + }); if let Some(targs) = ctor_targs { let recorded_vars = call_context.captured_vars(); self.solver().generalize_class_targs(targs, &recorded_vars); @@ -2562,7 +2567,8 @@ impl<'ctx, 'answer, Ans: LookupAnswer> AnswersSolver<'ctx, 'answer, Ans> { self.type_order(), call_boundary, ); - let errors = finish_result.map_or_else(|e| e.to_vec(), |_| Vec::new()); + let mut errors = finish_result.map_or_else(|e| e.to_vec(), |_| Vec::new()); + errors.extend(restriction_errors); // Apply meta-shape inference if bound args were collected let ret = if let Some(meta_shape_func) = meta_shape_func diff --git a/pyrefly/lib/solver/solver.rs b/pyrefly/lib/solver/solver.rs index c03e0b99ee..efb093073e 100644 --- a/pyrefly/lib/solver/solver.rs +++ b/pyrefly/lib/solver/solver.rs @@ -305,9 +305,11 @@ enum Variable { /// cannot enforce the parameter's restriction on its own. Argument matching checks each argument /// against `param` instead and records the first violation in `error`. /// -/// `finish_quantified_with_captures` reports the violation and takes the whole record off the answer, -/// which also keeps `param` out of published answers: `sanitize_vars` traverses only the answer -/// type, so a parameter left here could hide a variable that never gets pinned. +/// Each call that matches arguments against the answer takes the violation it caused, so that an +/// overload that causes one is rejected like it is without the expected type. +/// `finish_quantified_with_captures` reports any violation left and takes the whole record off the +/// answer, which also keeps `param` out of published answers: `sanitize_vars` traverses only the +/// answer type, so a parameter left here could hide a variable that never gets pinned. /// /// The violation is recorded here rather than in `Solver::instantiation_errors` because an entry in /// that map means "solving this variable went wrong", which makes `with_snapshot` reject the @@ -2463,6 +2465,29 @@ impl Solver { self.generalize_class_targs_impl(targs, vars_with_overload_branches, true) } + /// Take the restriction violations that argument matching recorded on the restricted answers + /// in `targs`. See [`RestrictedAnswer`]. + /// + /// `targs` must not have been expanded yet, because expansion replaces each answer's var with + /// its type and leaves nothing to take from. + pub fn take_restricted_answer_errors(&self, targs: &TArgs) -> Vec { + let lock = self.variables.lock(); + targs + .as_slice() + .iter() + .filter_map(|t| { + let Type::Var(v) = t else { return None }; + match &mut *lock.get_mut(*v) { + Variable::Answer { + restricted: Some(restricted), + .. + } => restricted.error.take(), + _ => None, + } + }) + .collect() + } + fn generalize_class_targs_impl( &self, targs: &mut TArgs, @@ -4176,8 +4201,8 @@ impl<'solver, 'subset, Ans: LookupAnswer> Subset<'solver, 'subset, Ans> { ty: t2, restricted, .. } => { let t2 = t2.clone(); - // Only the first violation is kept, so a parameter that has already been - // rejected needs no further checking. + // Only the first violation per call is kept, so a parameter that this call + // has already rejected needs no further checking. let param = restricted .as_ref() .filter(|r| r.error.is_none()) diff --git a/pyrefly/lib/test/generic_restrictions.rs b/pyrefly/lib/test/generic_restrictions.rs index 523b6e7a54..d376c7d37c 100644 --- a/pyrefly/lib/test/generic_restrictions.rs +++ b/pyrefly/lib/test/generic_restrictions.rs @@ -214,7 +214,6 @@ def f() -> None: // Modeled on `pd.Series(pd.NA, dtype="Int64")` under a bare `-> pd.Series` annotation. testcase!( - bug = "A restriction violation under a gradual expected type does not reject the overload", test_gradual_expected_type_restriction_violation_rejects_overload, r#" from typing import Any, Self, assert_type, overload @@ -234,14 +233,13 @@ def unhinted() -> None: assert_type(Series(NA(), dtype="Int64"), Series[Any]) def hinted() -> Series: - return Series(NA(), dtype="Int64") # E: `NA` is not assignable to upper bound `int | str` + return Series(NA(), dtype="Int64") "#, ); -// Without overloads, the restriction violation is reported, and the other arguments are checked -// against the expected type's answer. +// A restriction violation discards the expected type, so the call reports what the unhinted call +// reports. testcase!( - bug = "The expected type hides the bad argument that the unhinted call reports", test_gradual_expected_type_restriction_violation_without_overloads, r#" from typing import Any @@ -253,7 +251,25 @@ def unhinted(left: int | str, right: int | str | bytes) -> None: Pair(left, right) # E: `int | str` is not assignable to any of constraints `int`, `str` # E: Argument `bytes | int | str` is not assignable to parameter `right` def hinted(left: int | str, right: int | str | bytes) -> Pair[Any]: - return Pair(left, right) # E: `int | str` is not assignable to any of constraints `int`, `str` + return Pair(left, right) # E: `int | str` is not assignable to any of constraints `int`, `str` # E: Argument `bytes | int | str` is not assignable to parameter `right` + "#, +); + +// When every overload violates the restriction, the closest overload reports the violation once. +testcase!( + test_gradual_expected_type_restriction_violation_in_every_overload, + r#" +from typing import Self, overload + +class Box[S: int]: + @overload + def __new__(cls, data: S) -> Self: ... + @overload + def __new__(cls, data: S, extra: int) -> Self: ... + def __new__(cls, data: S, extra: int = 0) -> Self: ... + +def hinted() -> Box: + return Box("s") # E: `str` is not assignable to upper bound `int` of type variable `S` "#, );