diff --git a/crates/ty_python_semantic/resources/mdtest/generics/legacy/paramspec.md b/crates/ty_python_semantic/resources/mdtest/generics/legacy/paramspec.md index cc2be45792eb0..3c63d510ed75b 100644 --- a/crates/ty_python_semantic/resources/mdtest/generics/legacy/paramspec.md +++ b/crates/ty_python_semantic/resources/mdtest/generics/legacy/paramspec.md @@ -1071,10 +1071,8 @@ def without_first(callback: Callback[Concatenate[Base, Q]]) -> Callable[Q, None] def original(first: Middle, /, value: str) -> None: ... wrapped = Callback(original) -# TODO: Should reveal `(value: str) -> None`. Needs ParamSpecs in the new constraint solver. -reveal_type(without_first(wrapped)) # revealed: (...) -> None -# TODO: Should reveal `(value: str) -> None`. Needs ParamSpecs in the new constraint solver. -reveal_type(without_first(Callback(original))) # revealed: (...) -> None +reveal_type(without_first(wrapped)) # revealed: (value: str) -> None +reveal_type(without_first(Callback(original))) # revealed: (value: str) -> None ``` ## Inferring through unions of structural `ParamSpec` protocols @@ -1334,6 +1332,186 @@ async def check(): await to_thread(C.make, 1, label=2) # error: [invalid-argument-type] "Expected `str`" ``` +### Preserving parameter kinds and defaults + +Capturing a parameter list preserves names, parameter kinds, defaults, and variadic parameters. + +```py +from typing import Callable, ParamSpec + +P = ParamSpec("P") + +def preserve(callback: Callable[P, int]) -> Callable[P, str]: + raise NotImplementedError + +def shaped(value: int, /, label: str = "x", *, enabled: bool = False) -> int: + return value + +def variadic(*args: int, **kwargs: str) -> int: + return 1 + +reveal_type(preserve(variadic)) # revealed: (*args: int, **kwargs: str) -> str +``` + +The resulting callable validates arguments against the captured parameter list. + +```py +wrapped = preserve(shaped) +# revealed: (value: int, /, label: str = "x", *, enabled: bool = False) -> str +reveal_type(wrapped) +wrapped(1) +wrapped(1, label="label", enabled=True) +wrapped(1, enabled="yes") # error: [invalid-argument-type] +wrapped() # error: [missing-argument] +``` + +### Parameter lists inferred from consumers + +A consumer of a callback supplies an upper bound on the callback's parameter list. + +```py +from typing import Callable, ParamSpec + +P = ParamSpec("P") + +def from_consumer(consumer: Callable[[Callable[P, None]], None]) -> Callable[P, None]: + raise NotImplementedError + +def consume(callback: Callable[[int], None]) -> None: ... + +reveal_type(from_consumer(consume)) # revealed: (int, /) -> None +``` + +A callback and a consumer can bound the same parameter list from opposite directions. + +```py +def between(callback: Callable[P, None], consumer: Callable[[Callable[P, None]], None]) -> Callable[P, None]: + return callback + +def accepts_object(value: object, /) -> None: ... +def accepts_str(value: str, /) -> None: ... + +reveal_type(between(accepts_object, consume)) # revealed: (value: object, /) -> None +between(accepts_str, consume) # error: [invalid-argument-type] + +def consumer_first(consumer: Callable[[Callable[P, None]], None], callback: Callable[P, None]) -> Callable[P, None]: + return callback + +reveal_type(consumer_first(consume, accepts_object)) # revealed: (int, /) -> None +``` + +### Type variable variance alongside a parameter list + +A captured parameter list does not change how other type variables follow variance. + +```py +from typing import Callable, Generic, ParamSpec, TypeVar + +P = ParamSpec("P") +T = TypeVar("T") +V = TypeVar("V", contravariant=True) + +class Consumer(Generic[V]): + def consume(self, value: V) -> None: ... + +def use(shape: Callable[P, None], consumer: Consumer[Callable[[T], None]], value: T) -> T: + return value + +def empty() -> None: ... +def _(consumer: Consumer[Callable[[object], None]]): + reveal_type(use(empty, consumer, 1)) # revealed: object +``` + +### Repeated parameter lists retain the first binding + +Later callbacks must accept the calls allowed by the first captured parameter list. + +```py +from typing import Callable, ParamSpec + +P = ParamSpec("P") + +def first(left: Callable[P, None], right: Callable[P, None]) -> Callable[P, None]: + return left + +def integer(value: int, /) -> None: ... +def anything(value: object, /) -> None: ... +def keyword(*, value: int) -> None: ... + +reveal_type(first(integer, anything)) # revealed: (value: int, /) -> None +first(anything, integer) # error: [invalid-argument-type] +first(integer, keyword) # error: [invalid-argument-type] +``` + +Later callbacks still supply return types while the first supplies the parameter list. + +```py +from typing import TypeVar + +R = TypeVar("R") + +def returning(left: Callable[P, None], right: Callable[P, R]) -> Callable[P, R]: + return right + +def returns_str(value: object, /) -> str: + return "" + +reveal_type(returning(integer, returns_str)) # revealed: (value: int, /) -> str +``` + +### Repeated parameter lists from generic instances + +The first instance or callback supplies `P`, even when its parameter list is gradual. + +```py +from typing import Callable, Generic, ParamSpec + +P = ParamSpec("P") + +class Callback(Generic[P]): + def __call__(self, *args: P.args, **kwargs: P.kwargs) -> None: ... + +def instances(left: Callback[P], right: Callback[P]) -> Callback[P]: + return left + +def instance_first(left: Callback[P], right: Callable[P, None]) -> Callback[P]: + return left + +def callable_first(left: Callable[P, None], right: Callback[P]) -> Callable[P, None]: + return left + +def integer(value: int, /) -> None: ... +def anything(value: object, /) -> None: ... +def _(concrete: Callback[[int]], gradual: Callback[...]) -> None: + reveal_type(instances(concrete, gradual)) # revealed: Callback[(int, /)] + reveal_type(instances(gradual, concrete)) # revealed: Callback[(...)] + reveal_type(instance_first(concrete, anything)) # revealed: Callback[(int, /)] + reveal_type(callable_first(integer, gradual)) # revealed: (value: int, /) -> None +``` + +### Empty parameter lists override defaults + +A missing callback uses the default parameter list. An explicit empty list remains empty. + +```py +from typing import Callable, Generic +from typing_extensions import ParamSpec + +P = ParamSpec("P", default=[int]) + +class OptionalCallback(Generic[P]): + callback: Callable[P, None] + + def __init__(self, callback: Callable[P, None] | None = None) -> None: ... + +def empty() -> None: ... + +reveal_type(OptionalCallback().callback) # revealed: (int, /) -> None +reveal_type(OptionalCallback(empty).callback) # revealed: () -> None +OptionalCallback(empty).callback() +OptionalCallback(empty).callback(1) # error: [too-many-positional-arguments] +``` + ### Forwarded arguments with type-variable bounds When a type variable is bounded by `LiteralString`, string literals are not promoted to `str` when @@ -1353,3 +1531,129 @@ def target(first: T, values: list[T]) -> None: ... target("a", ["a"]) forward(target, "a", ["a"]) ``` + +### Parameter lists after incomplete inference + +Inferring `T` can exceed the work limit without losing a known parameter list. + +`A | B` and `C | D` have four possible intersections; adding `E` exceeds the limit. + +```py +from typing import Callable, TypeVar +from typing_extensions import ParamSpec + +P = ParamSpec("P") +T = TypeVar("T") + +class A: ... +class B: ... +class C: ... +class D: ... +class E: ... + +def capture( + shape: Callable[P, None], + left: Callable[[T], None], + right: Callable[[T], None], +) -> Callable[P, T]: + raise NotImplementedError + +def shape(*, value: str) -> None: ... +def left(value: A | B) -> None: ... +def right_small(value: C | D) -> None: ... +def right_large(value: C | D | E) -> None: ... + +# revealed: (*, value: str) -> (A & C) | (B & C) | (A & D) | (B & D) +reveal_type(capture(shape, left, right_small)) + +result = capture(shape, left, right_large) +reveal_type(result) # revealed: (*, value: str) -> Unknown +result(value="a") +result(value=1) # error: [invalid-argument-type] +``` + +When no argument supplies a parameter list, its default still applies even though `T` is unknown. + +```py +from typing import Generic + +Q = ParamSpec("Q", default=[int]) + +class Defaulted(Generic[T, Q]): + callback: Callable[Q, T] + + def __init__(self, left: Callable[[T], None], right: Callable[[T], None]) -> None: ... + +defaulted = Defaulted(left, right_large).callback +reveal_type(defaulted) # revealed: (int, /) -> Unknown +defaulted(1) +defaulted("a") # error: [invalid-argument-type] +``` + +### Tuple inference alongside parameter lists + +A captured parameter list does not change inference from alternatives in a tuple argument. + +```py +from typing import Callable, ParamSpec, TypeVar + +P = ParamSpec("P") +T = TypeVar("T") + +def first(callback: Callable[P, None], value: tuple[T, ...]) -> T: + return value[0] + +def callback(*, label: str) -> None: ... +def check(value: tuple[int] | tuple[str]): + reveal_type(first(callback, value)) # revealed: int | str +``` + +### Preserving method variables on callable receivers + +Binding a callable receiver preserves the method's other type variables for argument inference. + +```py +from typing import Callable, ParamSpec, TypeVar + +P = ParamSpec("P") +T = TypeVar("T") + +class Callback: + def __call__(self, text: str) -> int: + return len(text) + + def call(self: Callable[P, int], value: T, /, *args: P.args, **kwargs: P.kwargs) -> T: + self(*args, **kwargs) + return value + +callback = Callback() +reveal_type(callback.call(1, "text")) # revealed: Literal[1] +reveal_type(callback.call("value", "text")) # revealed: Literal["value"] +callback.call(1, 2) # error: [invalid-argument-type] +``` + +### Capturing both constructor signatures + +Capturing a class should keep the required `__init__` argument even if `__new__` accepts anything. + +```py +from typing import Callable, ParamSpec, TypeVar +from typing_extensions import Self + +P = ParamSpec("P") +R = TypeVar("R") + +def capture(fn: Callable[P, R]) -> Callable[P, R]: + return fn + +class Example: + def __new__(cls, *args, **kwargs) -> Self: + return super().__new__(cls) + + def __init__(self, value: int) -> None: ... + +constructor = capture(Example) +reveal_type(constructor(1)) # revealed: Example +# TODO: Combine both constructor parameter lists and report [missing-argument]. +constructor() +``` diff --git a/crates/ty_python_semantic/resources/mdtest/generics/pep695/functions.md b/crates/ty_python_semantic/resources/mdtest/generics/pep695/functions.md index a9e9a4cd7268c..3acba26ee5dba 100644 --- a/crates/ty_python_semantic/resources/mdtest/generics/pep695/functions.md +++ b/crates/ty_python_semantic/resources/mdtest/generics/pep695/functions.md @@ -426,8 +426,8 @@ def repeated_polarity[T](container: MixedVariance[Callable[[], T], Callable[[], reveal_type(repeated_polarity(MixedVariance[Callable[[], Middle], Callable[[], Middle]](), Derived())) # revealed: Middle ``` -An unrelated variadic type parameter currently sends the entire inference context through the legacy -solver, so the ordinary callable loses the contravariant bound shown above. +An unrelated ParamSpec preserves contravariance; TypeVarTuples still lose it through legacy +inference. ```py def with_paramspec[T, **P](container: Contravariant[Callable[[], T]], value: T, unrelated: Callable[P, None]) -> T: @@ -438,8 +438,7 @@ def with_typevartuple[T, *Ts](container: Contravariant[Callable[[], T]], value: def unrelated(value: str) -> None: ... -# TODO: Should reveal `Derived` when an unrelated ParamSpec no longer disables contravariance. -reveal_type(with_paramspec(Contravariant[Callable[[], Middle]](), Derived(), unrelated)) # revealed: Middle +reveal_type(with_paramspec(Contravariant[Callable[[], Middle]](), Derived(), unrelated)) # revealed: Derived # TODO: Should reveal `Derived` when an unrelated TypeVarTuple no longer disables contravariance. reveal_type(with_typevartuple(Contravariant[Callable[[], Middle]](), Derived(), ("value",))) # revealed: Middle ``` @@ -2884,6 +2883,9 @@ reveal_type(into_regular_callable(ClassWithNewAndInit)) # revealed: (...) -> ClassWithNewAndInit reveal_type(accepts_callable(ClassWithNewAndInit)) # revealed: ClassWithNewAndInit +reveal_type(accepts_callable(ClassWithNewAndInit)(1)) +# TODO: Combine both constructor parameter lists and report [missing-argument]. +# revealed: ClassWithNewAndInit reveal_type(accepts_callable(ClassWithNewAndInit)()) class Meta(type): diff --git a/crates/ty_python_semantic/resources/mdtest/generics/pep695/paramspec.md b/crates/ty_python_semantic/resources/mdtest/generics/pep695/paramspec.md index c0779ed1fb83a..ec4965a72351a 100644 --- a/crates/ty_python_semantic/resources/mdtest/generics/pep695/paramspec.md +++ b/crates/ty_python_semantic/resources/mdtest/generics/pep695/paramspec.md @@ -618,6 +618,105 @@ class ParamSpecWithDefault5[**P1 = P2]: Most of these test cases are adopted from the [typing documentation on `ParamSpec` semantics](https://typing.python.org/en/latest/spec/generics.html#semantics). +### Parameter lists inferred from consumers + +A consumer of a callback supplies an upper bound on the callback's parameter list. + +```py +from typing import Callable + +def from_consumer[**P](consumer: Callable[[Callable[P, None]], None]) -> Callable[P, None]: + raise NotImplementedError + +def consume(callback: Callable[[int], None]) -> None: ... + +reveal_type(from_consumer(consume)) # revealed: (int, /) -> None +``` + +A callback and a consumer can bound the same parameter list from opposite directions. + +```py +def between[**P](callback: Callable[P, None], consumer: Callable[[Callable[P, None]], None]) -> Callable[P, None]: + return callback + +def accepts_object(value: object, /) -> None: ... +def accepts_str(value: str, /) -> None: ... + +reveal_type(between(accepts_object, consume)) # revealed: (value: object, /) -> None +between(accepts_str, consume) # error: [invalid-argument-type] + +def consumer_first[**P](consumer: Callable[[Callable[P, None]], None], callback: Callable[P, None]) -> Callable[P, None]: + return callback + +reveal_type(consumer_first(consume, accepts_object)) # revealed: (int, /) -> None +``` + +### Type variable variance alongside a parameter list + +A captured parameter list does not change how other type variables follow variance. + +```py +from typing import Callable + +class Consumer[V]: + def consume(self, value: V) -> None: ... + +def use[**P, T](shape: Callable[P, None], consumer: Consumer[Callable[[T], None]], value: T) -> T: + return value + +def empty() -> None: ... +def _(consumer: Consumer[Callable[[object], None]]): + reveal_type(use(empty, consumer, 1)) # revealed: object +``` + +### Repeated parameter lists from generic instances + +The first instance or callback supplies `P`, even when its parameter list is gradual. + +```py +from typing import Callable + +class Callback[**P]: + def __call__(self, *args: P.args, **kwargs: P.kwargs) -> None: ... + +def instances[**P](left: Callback[P], right: Callback[P]) -> Callback[P]: + return left + +def instance_first[**P](left: Callback[P], right: Callable[P, None]) -> Callback[P]: + return left + +def callable_first[**P](left: Callable[P, None], right: Callback[P]) -> Callable[P, None]: + return left + +def integer(value: int, /) -> None: ... +def anything(value: object, /) -> None: ... +def _(concrete: Callback[[int]], gradual: Callback[...]) -> None: + reveal_type(instances(concrete, gradual)) # revealed: Callback[(int, /)] + reveal_type(instances(gradual, concrete)) # revealed: Callback[(...)] + reveal_type(instance_first(concrete, anything)) # revealed: Callback[(int, /)] + reveal_type(callable_first(integer, gradual)) # revealed: (value: int, /) -> None +``` + +### Empty parameter lists override defaults + +A missing callback uses the default parameter list. An explicit empty list remains empty. + +```py +from typing import Callable + +class OptionalCallback[**P = [int]]: + callback: Callable[P, None] + + def __init__(self, callback: Callable[P, None] | None = None) -> None: ... + +def empty() -> None: ... + +reveal_type(OptionalCallback().callback) # revealed: (int, /) -> None +reveal_type(OptionalCallback(empty).callback) # revealed: () -> None +OptionalCallback(empty).callback() +OptionalCallback(empty).callback(1) # error: [too-many-positional-arguments] +``` + ### Return type change using `ParamSpec` once ```py @@ -671,6 +770,26 @@ f3(1) f3("a", "b") ``` +Parameter kinds, defaults, and variadic annotations survive return-type changes. + +```py +def shaped(value: int, /, label: str = "x", *, enabled: bool = False) -> int: + return value + +def variadic(*args: int, **kwargs: str) -> int: + return 1 + +reveal_type(converter(variadic)) # revealed: (*args: int, **kwargs: str) -> bool + +wrapped = converter(shaped) +# revealed: (value: int, /, label: str = "x", *, enabled: bool = False) -> bool +reveal_type(wrapped) +wrapped(1) +wrapped(1, label="label", enabled=True) +wrapped(1, enabled="yes") # error: [invalid-argument-type] +wrapped() # error: [missing-argument] +``` + ### Prefer the declared parameter list We prefer the declared parameter list of a `ParamSpec` when it is compatible with the callback's @@ -823,6 +942,35 @@ def keyword_only2(*, y: int) -> int: reveal_type(multiple(keyword_only1, keyword_only2)) # revealed: (*, x: int) -> bool ``` +Later callbacks must accept the calls allowed by the first captured parameter list. + +```py +def integer(value: int, /) -> int: + return value + +def anything(value: object, /) -> int: + return 1 + +def keyword(*, value: int) -> int: + return value + +reveal_type(multiple(integer, anything)) # revealed: (value: int, /) -> bool +multiple(anything, integer) # error: [invalid-argument-type] +multiple(integer, keyword) # error: [invalid-argument-type] +``` + +Later callbacks still supply return types while the first supplies the parameter list. + +```py +def returning[**P, R](left: Callable[P, int], right: Callable[P, R]) -> Callable[P, R]: + return right + +def returns_str(value: object, /) -> str: + return "" + +reveal_type(returning(integer, returns_str)) # revealed: (value: int, /) -> str +``` + ### Constructors of user-defined generic class on `ParamSpec` ```py @@ -1375,6 +1523,27 @@ callback.call() # error: [missing-argument] "No argument provided for required callback.call(1, 2) # error: [too-many-positional-arguments] ``` +### Preserving method variables on callable receivers + +Binding a callable receiver preserves the method's other type variables for argument inference. + +```py +from typing import Callable + +class Callback: + def __call__(self, text: str) -> int: + return len(text) + + def call[**P, T](self: Callable[P, int], value: T, /, *args: P.args, **kwargs: P.kwargs) -> T: + self(*args, **kwargs) + return value + +callback = Callback() +reveal_type(callback.call(1, "text")) # revealed: Literal[1] +reveal_type(callback.call("value", "text")) # revealed: Literal["value"] +callback.call(1, 2) # error: [invalid-argument-type] +``` + ### Overloaded methods with generic receivers The mutable callback attribute makes `Wrapper` invariant in `P`, so its receiver determines each @@ -1942,6 +2111,71 @@ reveal_type(run(multi, x=1, y=2)) # revealed: int | str reveal_type(run(multi, 1, "b")) # revealed: int | str ``` +### Parameter lists after incomplete inference + +Inferring `T` can exceed the work limit without losing a known parameter list. + +`A | B` and `C | D` have four possible intersections; adding `E` exceeds the limit. + +```py +from typing import Callable + +class A: ... +class B: ... +class C: ... +class D: ... +class E: ... + +def capture[**P, T]( + shape: Callable[P, None], + left: Callable[[T], None], + right: Callable[[T], None], +) -> Callable[P, T]: + raise NotImplementedError + +def shape(*, value: str) -> None: ... +def left(value: A | B) -> None: ... +def right_small(value: C | D) -> None: ... +def right_large(value: C | D | E) -> None: ... + +# revealed: (*, value: str) -> (A & C) | (B & C) | (A & D) | (B & D) +reveal_type(capture(shape, left, right_small)) + +result = capture(shape, left, right_large) +reveal_type(result) # revealed: (*, value: str) -> Unknown +result(value="a") +result(value=1) # error: [invalid-argument-type] +``` + +When no argument supplies a parameter list, its default still applies even though `T` is unknown. + +```py +class Defaulted[T, **P = [int]]: + callback: Callable[P, T] + + def __init__(self, left: Callable[[T], None], right: Callable[[T], None]) -> None: ... + +defaulted = Defaulted(left, right_large).callback +reveal_type(defaulted) # revealed: (int, /) -> Unknown +defaulted(1) +defaulted("a") # error: [invalid-argument-type] +``` + +### Tuple inference alongside parameter lists + +A captured parameter list does not change inference from alternatives in a tuple argument. + +```py +from typing import Callable + +def first[**P, T](callback: Callable[P, None], value: tuple[T, ...]) -> T: + return value[0] + +def callback(*, label: str) -> None: ... +def check(value: tuple[int] | tuple[str]): + reveal_type(first(callback, value)) # revealed: int | str +``` + ### Overloads with substitution of `P.args` and `P.kwargs` This is regression test for diff --git a/crates/ty_python_semantic/src/types/generics.rs b/crates/ty_python_semantic/src/types/generics.rs index 711ced27a75e4..ec1874f6bac0e 100644 --- a/crates/ty_python_semantic/src/types/generics.rs +++ b/crates/ty_python_semantic/src/types/generics.rs @@ -2518,6 +2518,11 @@ pub(crate) struct SpecializationBuilder<'db, 'c> { inferable: TypeVarSet<'db>, pending: ConstraintSet<'db, 'c>, types: LegacyTypeMappings<'db>, + /// Keep the first supplied parameter list. Argument checking still validates later + /// occurrences against the chosen list. + /// + /// TODO: Combine repeated `ParamSpec` bounds using unions and intersections of parameter lists + /// instead of keeping only the first occurrence's contribution. paramspec_seen: FxHashSet>, } @@ -2537,10 +2542,10 @@ enum LegacyTypeMappings<'db> { pub(crate) struct TypeVarInference<'db> { #[returns(copy)] pub(crate) generic_context: GenericContext<'db>, - /// Inferred types in generic-context order. Multiple solutions are union-merged per variable, - /// including fallback types from incomplete solution families. This projection loses - /// correlations and completeness; `solutions` retains that information. When correlated - /// solutions are unavailable, this holds the compatibility or diagnostic recovery mapping. + /// Inferred types in generic-context order. Ordinary variables are union-merged, including + /// fallback types from incomplete solution families. Parameter lists keep the first available + /// choice. This projection loses correlations and completeness; `solutions` retains that + /// information. Unavailable families hold a diagnostic or recovery mapping. #[returns(deref)] merged_types: Box<[Option>]>, #[returns(ref)] @@ -2637,7 +2642,7 @@ pub(crate) enum TypeVarInferenceSolutions<'db> { #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, get_size2::GetSize, salsa::SalsaValue)] pub(crate) enum TypeVarInferenceFallback { Unconstrained, - Variadic, + TypeVarTuple, Unsatisfiable, BudgetExceeded, } @@ -2766,6 +2771,7 @@ impl<'db, 'c> SpecializationBuilder<'db, 'c> { &mut self, set: ConstraintSet<'db, 'c>, ) -> Result<(), SpecializationError<'db>> { + let set = self.remove_seen_paramspecs(set, &self.paramspec_seen); self.infer_from_constraint_set(set) } @@ -2880,7 +2886,7 @@ impl<'db, 'c> SpecializationBuilder<'db, 'c> { let when = actual.when_constraint_set_assignable_to(db, self.env, formal, self.constraints); let analysis = self.analyze_constraint_set(when); - self.project_for_legacy_fallback(&analysis); + self.record_constraint_analysis(&analysis); } let inference = @@ -3016,7 +3022,15 @@ impl<'db, 'c> SpecializationBuilder<'db, 'c> { solution: &[TypeVarSolution<'db>], ) { let db = self.db; + // TODO: Distinguish a ParamSpec binding lost to budget exhaustion from missing evidence. + // Without that distinction, defaults can apply after budget exhaustion. for binding in solution { + if binding.bound_typevar.is_paramspec(db) { + types + .entry(binding.bound_typevar.identity(db)) + .or_insert(binding.solution); + continue; + } types .entry(binding.bound_typevar.identity(db)) .and_modify(|existing| { @@ -3046,13 +3060,13 @@ impl<'db, 'c> SpecializationBuilder<'db, 'c> { { let db = self.db; let generic_context = self.generic_context; - // TODO: Move `ParamSpec` and `TypeVarTuple` handling to the new constraint solver. + // TODO: Move `TypeVarTuple` handling to the new constraint solver. if generic_context .variables(db) - .any(|typevar| typevar.is_paramspec(db) || typevar.is_typevartuple(db)) + .any(|typevar| typevar.is_typevartuple(db)) { return Ok( - self.compatibility_inference_with(TypeVarInferenceFallback::Variadic, choose) + self.compatibility_inference_with(TypeVarInferenceFallback::TypeVarTuple, choose) ); } @@ -3554,14 +3568,45 @@ impl<'db, 'c> SpecializationBuilder<'db, 'c> { } } - /// Adds available solutions, including fallback bindings, to the legacy inference mapping. - /// - /// This projection loses correlations between alternatives, so callers must only request it - /// after they have accepted the corresponding relation. - /// Omitting an accepted relation makes the legacy mapping unavailable for precise recovery. - /// - /// TODO: Remove this compatibility path once [`build_merged_with`][Self::build_merged_with] and all other - /// inference consumers can build specializations solely from the call-wide constraint set. + /// Record supplied parameter lists independently from the diagnostic recovery mapping. + fn record_constraint_analysis(&mut self, analysis: &ConstraintSetAnalysis<'db>) { + if let ConstraintSetAnalysis::Constrained(SolutionPaths::Complete(solutions)) = analysis { + self.record_paramspecs( + &solutions + .iter() + .map(|solution| solution.solved_typevars.as_slice()), + ); + } + self.project_for_legacy_fallback(analysis); + } + + fn record_paramspecs<'a>( + &mut self, + solutions: &(impl Iterator]> + Clone), + ) where + 'db: 'a, + { + for typevar in self + .generic_context + .variables(self.db) + .filter(|typevar| typevar.is_paramspec(self.db)) + { + // Different alternatives can supply different lists. They still belong to the + // first argument, so a later argument must not choose between them. + if solutions.clone().next().is_some() + && solutions.clone().all(|path| { + path.iter().any(|binding| { + binding.bound_typevar.identity(self.db) == typevar.identity(self.db) + }) + }) + { + self.paramspec_seen.insert(typevar.identity(self.db)); + } + } + } + + /// Adds available solutions, including fallback bindings, to the legacy recovery mapping. + /// Only accepted relations can contribute; omitted evidence makes precise recovery unsafe. fn project_for_legacy_fallback(&mut self, analysis: &ConstraintSetAnalysis<'db>) { if matches!(analysis, ConstraintSetAnalysis::BudgetExceeded) { self.types = LegacyTypeMappings::BudgetExceeded; @@ -3620,9 +3665,26 @@ impl<'db, 'c> SpecializationBuilder<'db, 'c> { /// Generic unsatisfiability is retained in `pending` rather than reported as a misleading /// type-variable declaration error. fn record_constraint_set(&mut self, when: ConstraintSet<'db, 'c>) { + let when = self.remove_seen_paramspecs(when, &self.paramspec_seen); self.pending.intersect(self.db, self.constraints, when); } + fn remove_seen_paramspecs( + &self, + when: ConstraintSet<'db, 'c>, + seen: &FxHashSet>, + ) -> ConstraintSet<'db, 'c> { + // Callable and protocol comparisons can supply several variables at once. Ignore later + // occurrences of a ParamSpec without losing the other variables' requirements. + let seen = TypeVarSet::from_typevars( + self.db, + self.generic_context.variables(self.db).filter(|typevar| { + seen.contains(&typevar.identity(self.db)) && when.mentions_typevar(*typevar) + }), + ); + when.reduce_inferable(self.db, self.env, self.constraints, seen) + } + /// Records a relation and projects its solutions into the legacy type mapping. /// /// Contextual preference checks, variadic inference, and recursive-specialization recovery @@ -3637,7 +3699,7 @@ impl<'db, 'c> SpecializationBuilder<'db, 'c> { if let Some(error) = analysis.specialization_error(db, self.env) { return Err(error); } - self.project_for_legacy_fallback(&analysis); + self.record_constraint_analysis(&analysis); Ok(()) } @@ -3932,6 +3994,25 @@ impl<'db, 'c> SpecializationBuilder<'db, 'c> { // Retain every alternative that was not proved unsatisfiable. Solving the // combined TDD here would repeat their potentially expensive path traversals. self.record_constraint_set(combined); + if accepted.iter().all(|(_, analysis)| { + matches!( + analysis, + ConstraintSetAnalysis::Constrained(SolutionPaths::Complete(_)) + ) + }) { + self.record_paramspecs( + &accepted + .iter() + .filter_map(|(_, analysis)| match analysis { + ConstraintSetAnalysis::Constrained(SolutionPaths::Complete( + paths, + )) => Some(paths), + _ => None, + }) + .flatten() + .map(|solution| solution.solved_typevars.as_slice()), + ); + } for (_, analysis) in accepted { self.project_for_legacy_fallback(&analysis); } @@ -4027,16 +4108,22 @@ impl<'db, 'c> SpecializationBuilder<'db, 'c> { .is_always_satisfied(db, self.env) }); - // ParamSpecs and TypeVarTuples still use the forward-only legacy mapping table. Keep - // their entire inference context on the existing signature path, and use forward - // structural relations so nested variadics and ordinary type variables retain their - // mappings. Preserve the original polarity for recursive and ordinary inference. - // TODO: Apply full polarity once variadics are supported by the new constraint solver. + // Parameter-list inference extracts the actual signature's shape, including a + // Concatenate tail. Argument validation separately checks the enclosing variance. let relation_polarity = if !polarity.is_covariant() - && self - .inferable - .iter(db) - .any(|typevar| typevar.is_paramspec(db) || typevar.is_typevartuple(db)) + && (any_over_type(db, self.env, formal, false, |ty| { + ty.as_typevar().is_some_and(|typevar| { + typevar.is_paramspec(db) + && typevar + .without_paramspec_attr(db) + .is_inferable(db, self.inferable) + }) + }) + // TODO: Apply full polarity once TypeVarTuple inference is migrated. + || self + .inferable + .iter(db) + .any(|typevar| typevar.is_typevartuple(db))) { TypeVarVariance::Covariant } else { @@ -4172,7 +4259,7 @@ impl<'db, 'c> SpecializationBuilder<'db, 'c> { let has_variadic = self .inferable .iter(db) - .any(|typevar| typevar.is_paramspec(db) || typevar.is_typevartuple(db)); + .any(|typevar| typevar.is_typevartuple(db)); if has_variadic { // TODO: // Variadic contexts still solve from legacy mappings. Projecting the relation @@ -4620,7 +4707,7 @@ impl<'db, 'c> SpecializationBuilder<'db, 'c> { && !self .inferable .iter(db) - .any(|typevar| typevar.is_paramspec(db) || typevar.is_typevartuple(db)) => + .any(|typevar| typevar.is_typevartuple(db)) => { let when = self.constraint_for_relation(formal, actual, relation_polarity); return self.infer_from_constraint_set(when); diff --git a/crates/ty_python_semantic/src/types/signatures.rs b/crates/ty_python_semantic/src/types/signatures.rs index c16c7d2d33734..639c3a7ca423f 100644 --- a/crates/ty_python_semantic/src/types/signatures.rs +++ b/crates/ty_python_semantic/src/types/signatures.rs @@ -1348,9 +1348,12 @@ impl<'db> Signature<'db> { return_ty.apply_type_mapping(db, env, &self_mapping, TypeContext::default()); } Self { + // If `Self` was the only type variable, the bound signature is nongeneric. + // Use `None` so it compares equal to other nongeneric signatures. generic_context: self .generic_context - .map(|generic_context| generic_context.remove_self(db, binding_context)), + .map(|generic_context| generic_context.remove_self(db, binding_context)) + .filter(|generic_context| generic_context.len(db) > 0), definition: self.definition, extras: SignatureExtras::new( self.source_overload_index_raw(), @@ -1442,6 +1445,10 @@ impl<'db> Signature<'db> { builder.add_constraint_set(when).ok()?; let concrete_class_receiver = matches!(receiver_type, Type::ClassLiteral(_) | Type::GenericAlias(_)); + let callable_receiver = generic_context + .variables(db) + .any(|typevar| typevar.is_paramspec(db)) + && receiver_type.try_upcast_to_callable(db, env).is_some(); let specialization = builder.build_merged_with(|typevar, bounds| { if let Some(bounds) = bounds && bounds.as_exact(db, env).is_some() @@ -1451,12 +1458,15 @@ impl<'db> Signature<'db> { return Some(solution); } + // A callable receiver supplies its parameter list even when the bound method uses + // the ParamSpec only in contravariant parameter positions. if let Some(bounds) = bounds - && concrete_class_receiver - && bound_signature - .variance_of(db, env, typevar.identity(db)) - .evaluate(db) - .is_covariant() + && (concrete_class_receiver || callable_receiver) + && (typevar.is_paramspec(db) + || bound_signature + .variance_of(db, env, typevar.identity(db)) + .evaluate(db) + .is_covariant()) && bounds .inference_lower(db, env) .is_some_and(|lower| !lower.is_never())