From 30a66b40d929a538d826b719e9858212330278d1 Mon Sep 17 00:00:00 2001 From: Mangesh Raut Date: Wed, 28 Oct 2026 09:06:32 +0530 Subject: [PATCH 1/3] fix(solver): keep explicit positional params under gradual *args/**kwargs When a callable ends in *args: Any, **kwargs: Any but also has an explicit positional prefix, assignability still has to check that prefix. Soft gradual subtyping was swallowing SubsetError from those positional checks; only SubsetError::Other should be softened. Fixes #5194 --- pyrefly/lib/solver/solver.rs | 3 +++ pyrefly/lib/solver/subset.rs | 20 ++++++++++++++++---- pyrefly/lib/test/callable.rs | 21 +++++++++++++++++++++ 3 files changed, 40 insertions(+), 4 deletions(-) diff --git a/pyrefly/lib/solver/solver.rs b/pyrefly/lib/solver/solver.rs index 688bf40cd3..f9414b3ac6 100644 --- a/pyrefly/lib/solver/solver.rs +++ b/pyrefly/lib/solver/solver.rs @@ -2982,6 +2982,8 @@ pub enum SubsetError { PosParamName(Name, Name), /// `got` does not accept positional parameters that `want` allows callers to pass. CallableMissingPositionalParameters(Vec), + /// Explicit positional callable parameters are incompatible. + CallablePositionalParameterMismatch, /// Instantiations for quantified vars are incompatible with bounds TypeVarSpecialization(Vec1), /// `got` is missing an attribute that the Protocol `want` requires @@ -3036,6 +3038,7 @@ impl SubsetError { ) }) } + SubsetError::CallablePositionalParameterMismatch => None, SubsetError::TypeVarSpecialization(_) => { // TODO None diff --git a/pyrefly/lib/solver/subset.rs b/pyrefly/lib/solver/subset.rs index e203027570..f50f2a5c97 100644 --- a/pyrefly/lib/solver/subset.rs +++ b/pyrefly/lib/solver/subset.rs @@ -251,7 +251,9 @@ impl<'solver, 'subset, Ans: LookupAnswer> Subset<'solver, 'subset, Ans> { // Don't short-circuit because we may want to pin/solve variables let result = self.is_subset_param_list_impl(l_args, u_args); match result { - Err(_) if !self.solver.config.strict_callable_subtyping && (l_gradual || u_gradual) => { + Err(SubsetError::Other) + if !self.solver.config.strict_callable_subtyping && (l_gradual || u_gradual) => + { Ok(()) } _ => result, @@ -287,7 +289,9 @@ impl<'solver, 'subset, Ans: LookupAnswer> Subset<'solver, 'subset, Ans> { Some(Param::PosOnly(_, l, l_req) | Param::Pos(_, l, l_req)), Some(Param::PosOnly(_, u, u_req)), ) if (*u_req == Required::Required || matches!(l_req, Required::Optional(_))) => { - self.is_subset_eq(u, l)?; + self + .is_subset_eq(u, l) + .map_err(|_| SubsetError::CallablePositionalParameterMismatch)?; l_arg = l_args.next(); u_arg = u_args.next(); } @@ -297,10 +301,16 @@ impl<'solver, 'subset, Ans: LookupAnswer> Subset<'solver, 'subset, Ans> { if l_name != u_name { return Err(SubsetError::PosParamName(l_name.clone(), u_name.clone())); } - self.is_subset_eq(u, l)?; + self + .is_subset_eq(u, l) + .map_err(|_| SubsetError::CallablePositionalParameterMismatch)?; l_arg = l_args.next(); u_arg = u_args.next(); } + ( + Some(Param::PosOnly(..) | Param::Pos(..)), + Some(Param::PosOnly(..) | Param::Pos(..)), + ) => return Err(SubsetError::CallablePositionalParameterMismatch), (Some(Param::Varargs(_, Type::Unpack(l))), None) => { self.is_subset_eq(&self.solver.heap.mk_concrete_tuple(Vec::new()), l)?; l_arg = l_args.next(); @@ -687,7 +697,9 @@ impl<'solver, 'subset, Ans: LookupAnswer> Subset<'solver, 'subset, Ans> { } }; match result { - Err(_) if !self.solver.config.strict_callable_subtyping && (l_gradual || u_gradual) => { + Err(SubsetError::Other) + if !self.solver.config.strict_callable_subtyping && (l_gradual || u_gradual) => + { Ok(()) } _ => result, diff --git a/pyrefly/lib/test/callable.rs b/pyrefly/lib/test/callable.rs index 17b60f3c4d..31b60d308f 100644 --- a/pyrefly/lib/test/callable.rs +++ b/pyrefly/lib/test/callable.rs @@ -2363,6 +2363,27 @@ def f(n: NoArgs) -> None: "#, ); +// https://github.com/facebook/pyrefly/issues/5194 +testcase!( + test_gradual_variadic_params_keep_explicit_prefix, + r#" +from typing import Any, Callable, Protocol + +class Gradual(Protocol): + def __call__(self, *args: Any, **kwargs: Any) -> None: ... + +class Prefixed(Protocol): + def __call__(self, value: str, /, *args: Any, **kwargs: Any) -> None: ... + +def prefixed_function(value: str, /, *args: Any, **kwargs: Any) -> None: ... + +def f(gradual: Gradual, prefixed: Prefixed) -> None: + accepted: Callable[[int], None] = gradual + protocol_error: Callable[[int], None] = prefixed # E: `Prefixed` is not assignable to `(int) -> None` + function_error: Callable[[int], None] = prefixed_function # E: `(value: str, /, *args: Any, **kwargs: Any) -> None` is not assignable to `(int) -> None` +"#, +); + testcase!( test_defaultdict_frozenset, r#" From 8cc1d2899e2d26df4548a4f3c2348cb1bd85b2ec Mon Sep 17 00:00:00 2001 From: Mangesh Raut <95149253+mangeshraut712@users.noreply.github.com> Date: Wed, 28 Oct 2026 03:47:05 +0000 Subject: [PATCH 2/3] Apply cargo fmt --- pyrefly/lib/solver/subset.rs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/pyrefly/lib/solver/subset.rs b/pyrefly/lib/solver/subset.rs index f50f2a5c97..8a753f3d33 100644 --- a/pyrefly/lib/solver/subset.rs +++ b/pyrefly/lib/solver/subset.rs @@ -289,8 +289,7 @@ impl<'solver, 'subset, Ans: LookupAnswer> Subset<'solver, 'subset, Ans> { Some(Param::PosOnly(_, l, l_req) | Param::Pos(_, l, l_req)), Some(Param::PosOnly(_, u, u_req)), ) if (*u_req == Required::Required || matches!(l_req, Required::Optional(_))) => { - self - .is_subset_eq(u, l) + self.is_subset_eq(u, l) .map_err(|_| SubsetError::CallablePositionalParameterMismatch)?; l_arg = l_args.next(); u_arg = u_args.next(); @@ -301,8 +300,7 @@ impl<'solver, 'subset, Ans: LookupAnswer> Subset<'solver, 'subset, Ans> { if l_name != u_name { return Err(SubsetError::PosParamName(l_name.clone(), u_name.clone())); } - self - .is_subset_eq(u, l) + self.is_subset_eq(u, l) .map_err(|_| SubsetError::CallablePositionalParameterMismatch)?; l_arg = l_args.next(); u_arg = u_args.next(); From daf4ea002fc85cdbb1bc0787f2118acc354a8e64 Mon Sep 17 00:00:00 2001 From: Mangesh Raut <95149253+mangeshraut712@users.noreply.github.com> Date: Wed, 28 Oct 2026 04:07:24 +0000 Subject: [PATCH 3/3] Restore gradual base *args/**kwargs: Any accepts any override Soft-match: u_gradual (want/base) softens all param-list errors under non-strict callable subtyping so Child.method1() subtypes GradualBase ending in *args: Any, **kwargs: Any. Keep l_gradual limited to Other so explicit positional-prefix mismatches still fail (#5194). --- pyrefly/lib/solver/subset.rs | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/pyrefly/lib/solver/subset.rs b/pyrefly/lib/solver/subset.rs index 8a753f3d33..8ced6b51c8 100644 --- a/pyrefly/lib/solver/subset.rs +++ b/pyrefly/lib/solver/subset.rs @@ -251,8 +251,12 @@ impl<'solver, 'subset, Ans: LookupAnswer> Subset<'solver, 'subset, Ans> { // Don't short-circuit because we may want to pin/solve variables let result = self.is_subset_param_list_impl(l_args, u_args); match result { + // A gradual want/base (`*args: Any, **kwargs: Any`) accepts any got/override. + Err(_) if !self.solver.config.strict_callable_subtyping && u_gradual => Ok(()), + // A gradual got still softens unstructured mismatches, but not explicit + // positional prefix failures (see CallablePositionalParameterMismatch). Err(SubsetError::Other) - if !self.solver.config.strict_callable_subtyping && (l_gradual || u_gradual) => + if !self.solver.config.strict_callable_subtyping && l_gradual => { Ok(()) } @@ -695,8 +699,12 @@ impl<'solver, 'subset, Ans: LookupAnswer> Subset<'solver, 'subset, Ans> { } }; match result { + // A gradual want/base (`*args: Any, **kwargs: Any`) accepts any got/override. + Err(_) if !self.solver.config.strict_callable_subtyping && u_gradual => Ok(()), + // A gradual got still softens unstructured mismatches, but not explicit + // positional prefix failures (see CallablePositionalParameterMismatch). Err(SubsetError::Other) - if !self.solver.config.strict_callable_subtyping && (l_gradual || u_gradual) => + if !self.solver.config.strict_callable_subtyping && l_gradual => { Ok(()) }