diff --git a/pyrefly/lib/solver/solver.rs b/pyrefly/lib/solver/solver.rs index 9b8f548700..34e8af51ac 100644 --- a/pyrefly/lib/solver/solver.rs +++ b/pyrefly/lib/solver/solver.rs @@ -2985,6 +2985,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 @@ -3039,6 +3041,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 a013d9c6fc..c47ae3b33e 100644 --- a/pyrefly/lib/solver/subset.rs +++ b/pyrefly/lib/solver/subset.rs @@ -250,7 +250,13 @@ 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) => { + // 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 => + { Ok(()) } _ => result, @@ -286,7 +292,8 @@ 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(); } @@ -296,10 +303,15 @@ 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(); @@ -686,7 +698,13 @@ impl<'solver, 'subset, Ans: LookupAnswer> Subset<'solver, 'subset, Ans> { } }; match result { - Err(_) if !self.solver.config.strict_callable_subtyping && (l_gradual || u_gradual) => { + // 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 => + { 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#"