From d483dde641a114f4e3caa08d000330c090b1cf41 Mon Sep 17 00:00:00 2001 From: Dhruv Manilawala Date: Tue, 25 Aug 2026 11:19:01 +0530 Subject: [PATCH 01/15] [ty] Align ConstraintSet.range with its stub Allow the three required arguments to be passed by keyword, matching the existing internal stub. --- crates/ty_python_semantic/src/types/method.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/ty_python_semantic/src/types/method.rs b/crates/ty_python_semantic/src/types/method.rs index 05df72fd936ea4..050843e3de4adb 100644 --- a/crates/ty_python_semantic/src/types/method.rs +++ b/crates/ty_python_semantic/src/types/method.rs @@ -533,11 +533,11 @@ impl<'db> KnownBoundMethodType<'db> { KnownBoundMethodType::ConstraintSetRange => { Either::Right(std::iter::once(Signature::new( Parameters::standard([ - Parameter::positional_only(Some(Name::new_static("lower_bound"))) + Parameter::positional_or_keyword(Name::new_static("lower_bound")) .with_annotated_type(object_type_form()), - Parameter::positional_only(Some(Name::new_static("typevar"))) + Parameter::positional_or_keyword(Name::new_static("typevar")) .with_annotated_type(object_type_form()), - Parameter::positional_only(Some(Name::new_static("upper_bound"))) + Parameter::positional_or_keyword(Name::new_static("upper_bound")) .with_annotated_type(object_type_form()), ]), KnownClass::ConstraintSet.to_instance(db, env), From d083280b1f9153302a2e68fbcbc933936fdf2b12 Mon Sep 17 00:00:00 2001 From: Dhruv Manilawala Date: Tue, 25 Aug 2026 11:21:20 +0530 Subject: [PATCH 02/15] [ty] Support callable bounds for ParamSpec ranges Accept bare ParamSpec subjects in the internal range constructor and normalize callable bounds to parameter-list values. Preserve full signatures and overloads while erasing returns, and keep ordinary bounds and type-form validation unchanged. Cover concrete, aliased, gradual, and overloaded bounds, including invalid forms and a computed-receiver validation regression. --- .../mdtest/type_properties/constraints.md | 370 +++++++++++++++++- .../ty_python_semantic/src/types/call/bind.rs | 16 + .../ty_python_semantic/src/types/callable.rs | 15 + .../src/types/infer/builder.rs | 44 ++- 4 files changed, 434 insertions(+), 11 deletions(-) diff --git a/crates/ty_python_semantic/resources/mdtest/type_properties/constraints.md b/crates/ty_python_semantic/resources/mdtest/type_properties/constraints.md index c1c887c3755d67..49e364bb609fba 100644 --- a/crates/ty_python_semantic/resources/mdtest/type_properties/constraints.md +++ b/crates/ty_python_semantic/resources/mdtest/type_properties/constraints.md @@ -33,7 +33,7 @@ typevar can only specialize to a type that is a supertype of the lower bound, an upper bound. ```py -from typing import Any, final, Never, Sequence +from typing import Any, Callable, final, Never, Sequence from ty_extensions import static_assert from ty_extensions._internal import ConstraintSet @@ -86,6 +86,13 @@ def _[T]() -> None: static_assert(not ConstraintSet.range(Base, T, Unrelated)) ``` +Ordinary TypeVar bounds compare whole callables, so incompatible returns make a range unsatisfiable. + +```py +def callable_returns[T]() -> None: + static_assert(ConstraintSet.range(Callable[[int], int], T, Callable[[int], str]) == ConstraintSet.never()) +``` + When the lower and upper bounds are the same type, `equality` requires the typevar to specialize to that specific type. @@ -1167,7 +1174,7 @@ out all of the different kinds of constraints described above. Here we just test exists, and provides more detail than otherwise. ```py -from ty_extensions._internal import ConstraintSet +from ty_extensions._internal import ConstraintSet, RegularCallableTypeOf class Super: ... class Base(Super): ... @@ -1181,3 +1188,362 @@ def _[T]() -> None: # revealed: ConstraintSet[(Sub ≤ T@_ ≤ Super)] reveal_type(ConstraintSet.range(Sub, T, Super).with_detailed_display()) ``` + +ParamSpec bounds display the full parameter list without the callable return type. + +```py +def complete(value: int, /, text: str = "", *args: float, flag: bool = False, **kwargs: bytes) -> int: + return 0 + +def signature[**P]() -> None: + constraints = ConstraintSet.range(RegularCallableTypeOf[complete], P, RegularCallableTypeOf[complete]) + # revealed: ConstraintSet[(P@signature = (value: int, /, text: str = "", *args: float, flag: bool = False, **kwargs: bytes))] + reveal_type(constraints.with_detailed_display()) +``` + +## ParamSpec + +A ParamSpec range is `lower_parameters ≤ P ≤ upper_parameters`; callable returns are ignored. + +### Range construction + +A legacy ParamSpec bound by a generic callable is valid as the subject of a nontrivial range. + +```py +from typing import Callable, ParamSpec +from ty_extensions import static_assert +from ty_extensions._internal import ConstraintSet + +P = ParamSpec("P") + +def legacy(callback: Callable[P, None]) -> None: + constraints = ConstraintSet.range(Callable[[int, str], None], P, Callable[[int, str], None]) + reveal_type(constraints) # revealed: ConstraintSet[bool] + different_returns = ConstraintSet.range(Callable[[int, str], int], P, Callable[[int, str], str]) + static_assert(constraints == different_returns) +``` + +An empty parameter list is an exact bound, distinct from a one-parameter list. + +```py +def empty[**P]() -> None: + constraints = ConstraintSet.range(Callable[[], None], P, Callable[[], None]) + reveal_type(constraints) # revealed: ConstraintSet[bool] + static_assert(constraints != ConstraintSet.range(Callable[[int], None], P, Callable[[int], None])) +``` + +### Qualified subjects + +A module-qualified legacy ParamSpec is valid when bound by the enclosing function. + +`params.py`: + +```py +from typing import ParamSpec + +P = ParamSpec("P") +``` + +`main.py`: + +```py +from typing import Callable +from ty_extensions._internal import ConstraintSet +import params + +def qualified(callback: Callable[params.P, None]) -> None: + constraints = ConstraintSet.range(Callable[[int], None], params.P, Callable[[int], None]) + reveal_type(constraints) # revealed: ConstraintSet[bool] +``` + +### Callable aliases + +Specialized callable aliases have the same bounds as their expanded parameter lists. + +```py +from typing import Callable, Concatenate +from ty_extensions import static_assert +from ty_extensions._internal import ConstraintSet + +type Callback[**Q, R] = Callable[Q, R] + +def aliases[**P]() -> None: + constraints = ConstraintSet.range(Callback[[int, str, bool], int], P, Callback[[int, str, bool], str]) + reveal_type(constraints) # revealed: ConstraintSet[bool] + expected = ConstraintSet.range(Callable[[int, str, bool], None], P, Callable[[int, str, bool], None]) + static_assert(constraints == expected) +``` + +Fully specializing a `Concatenate` alias preserves every prefix parameter and the concrete tail. + +```py +type Prefixed[**Q, R] = Callable[Concatenate[int, str, Q], R] + +def concatenate[**P]() -> None: + constraints = ConstraintSet.range(Prefixed[[bool], int], P, Prefixed[[bool], str]) + reveal_type(constraints) # revealed: ConstraintSet[bool] + expected = ConstraintSet.range(Callable[[int, str, bool], None], P, Callable[[int, str, bool], None]) + static_assert(constraints == expected) +``` + +### Two-sided bounds + +A callable accepting `Super` and a consumer passing a `Sub` give `(Super, /) ≤ P ≤ (Sub, /)`. + +```py +from typing import Callable, final +from ty_extensions import static_assert +from ty_extensions._internal import ConstraintSet + +class Super: ... +class Base(Super): ... +class Sub(Base): ... + +@final +class Unrelated: ... + +def two_sided[**P]() -> None: + constraints = ConstraintSet.range(Callable[[Super], None], P, Callable[[Sub], None]) + reveal_type(constraints) # revealed: ConstraintSet[bool] + static_assert(constraints != ConstraintSet.range(Callable[[Super], None], P, Callable[[Super], None])) + static_assert(constraints != ConstraintSet.range(Callable[[Sub], None], P, Callable[[Sub], None])) +``` + +Inverted or incomparable bounds are unsatisfiable. + +```py +def incompatible[**P]() -> None: + inverted = ConstraintSet.range(Callable[[Sub], None], P, Callable[[Super], None]) + static_assert(inverted == ConstraintSet.never()) + incomparable = ConstraintSet.range(Callable[[Base], None], P, Callable[[Unrelated], None]) + static_assert(incomparable == ConstraintSet.never()) +``` + +### Signature preservation + +Named parameters accept positional-only calls; the reverse range is invalid. + +```pyi +from typing import Callable +from ty_extensions import static_assert +from ty_extensions._internal import ConstraintSet, RegularCallableTypeOf + +def named(value: int) -> None: ... +def positional_only[**P]() -> None: + constraints = ConstraintSet.range(RegularCallableTypeOf[named], P, Callable[[int], None]) + reveal_type(constraints) # revealed: ConstraintSet[bool] + reverse = ConstraintSet.range(Callable[[int], None], P, RegularCallableTypeOf[named]) + static_assert(reverse == ConstraintSet.never()) +``` + +Named parameters also accept keyword-only calls; the reverse range is invalid. + +```pyi +def keyword(*, value: int) -> None: ... +def keyword_only[**P]() -> None: + constraints = ConstraintSet.range(RegularCallableTypeOf[named], P, RegularCallableTypeOf[keyword]) + reveal_type(constraints) # revealed: ConstraintSet[bool] + reverse = ConstraintSet.range(RegularCallableTypeOf[keyword], P, RegularCallableTypeOf[named]) + static_assert(reverse == ConstraintSet.never()) +``` + +An optional parameter accepts every call to a required parameter, but not the reverse. + +```pyi +def optional(value: int = ...) -> None: ... +def defaults[**P]() -> None: + constraints = ConstraintSet.range(RegularCallableTypeOf[optional], P, RegularCallableTypeOf[named]) + reveal_type(constraints) # revealed: ConstraintSet[bool] + reverse = ConstraintSet.range(RegularCallableTypeOf[named], P, RegularCallableTypeOf[optional]) + static_assert(reverse == ConstraintSet.never()) +``` + +Variadic positional parameters accept fixed positional lists, but not the reverse. + +```pyi +def args(*args: int) -> None: ... +def positional_variadics[**P]() -> None: + constraints = ConstraintSet.range(RegularCallableTypeOf[args], P, Callable[[int, int], None]) + reveal_type(constraints) # revealed: ConstraintSet[bool] + reverse = ConstraintSet.range(Callable[[int, int], None], P, RegularCallableTypeOf[args]) + static_assert(reverse == ConstraintSet.never()) +``` + +Variadic keyword parameters likewise accept a fixed keyword-only parameter, but not the reverse. + +```pyi +def kwargs(**kwargs: int) -> None: ... +def keyword_variadics[**P]() -> None: + constraints = ConstraintSet.range(RegularCallableTypeOf[kwargs], P, RegularCallableTypeOf[keyword]) + reveal_type(constraints) # revealed: ConstraintSet[bool] + reverse = ConstraintSet.range(RegularCallableTypeOf[keyword], P, RegularCallableTypeOf[kwargs]) + static_assert(reverse == ConstraintSet.never()) +``` + +### Overloaded bounds + +Return types are erased in every overload, without keeping only the first or last parameter list. + +```pyi +from typing import Callable, overload +from ty_extensions import static_assert +from ty_extensions._internal import ConstraintSet, RegularCallableTypeOf + +@overload +def overloaded(value: int, /) -> int: ... +@overload +def overloaded(*, value: str) -> str: ... +@overload +def swapped_returns(value: int, /) -> str: ... +@overload +def swapped_returns(*, value: str) -> int: ... +def keyword(*, value: str) -> None: ... +def overloads[**P]() -> None: + constraints = ConstraintSet.range(RegularCallableTypeOf[overloaded], P, RegularCallableTypeOf[swapped_returns]) + reveal_type(constraints) # revealed: ConstraintSet[bool] + static_assert(constraints == ConstraintSet.range(RegularCallableTypeOf[overloaded], P, RegularCallableTypeOf[overloaded])) + static_assert(constraints != ConstraintSet.range(Callable[[int], None], P, Callable[[int], None])) + static_assert(constraints != ConstraintSet.range(RegularCallableTypeOf[keyword], P, RegularCallableTypeOf[keyword])) +``` + +An overloaded lower bound can satisfy a single signature; an overloaded upper bound requires both. + +```pyi +def asymmetric[**P]() -> None: + constraints = ConstraintSet.range(RegularCallableTypeOf[overloaded], P, Callable[[int], None]) + reveal_type(constraints) # revealed: ConstraintSet[bool] + reverse = ConstraintSet.range(Callable[[int], None], P, RegularCallableTypeOf[overloaded]) + static_assert(reverse == ConstraintSet.never()) +``` + +The string overload accepts only a keyword argument, not a positional argument. + +```pyi +def parameter_kinds[**P]() -> None: + constraints = ConstraintSet.range(RegularCallableTypeOf[overloaded], P, RegularCallableTypeOf[keyword]) + reveal_type(constraints) # revealed: ConstraintSet[bool] + positional = ConstraintSet.range(RegularCallableTypeOf[overloaded], P, Callable[[str], None]) + static_assert(positional == ConstraintSet.never()) +``` + +### Gradual parameter lists + +Ellipsis describes gradual parameters; `Any` annotates one required positional-only parameter. + +```py +from typing import Any, Callable +from ty_extensions import static_assert +from ty_extensions._internal import ConstraintSet + +def gradual[**P]() -> None: + ellipsis = ConstraintSet.range(Callable[..., int], P, Callable[..., str]) + reveal_type(ellipsis.with_detailed_display()) # revealed: ConstraintSet[(P@gradual = (...))] + any_parameter = ConstraintSet.range(Callable[[Any], int], P, Callable[[Any], str]) + reveal_type(any_parameter.with_detailed_display()) # revealed: ConstraintSet[(P@gradual = (Any, /))] +``` + +Only ellipsis is compatible with an empty parameter list in either bound. + +```py +def empty[**P]() -> None: + reveal_type(ConstraintSet.range(Callable[..., None], P, Callable[[], None])) # revealed: ConstraintSet[bool] + reveal_type(ConstraintSet.range(Callable[[], None], P, Callable[..., None])) # revealed: ConstraintSet[bool] + static_assert(ConstraintSet.range(Callable[[Any], None], P, Callable[[], None]) == ConstraintSet.never()) + static_assert(ConstraintSet.range(Callable[[], None], P, Callable[[Any], None]) == ConstraintSet.never()) +``` + +### Invalid forms and preservation controls + +An ordinary type in either bound makes a ParamSpec range unsatisfiable. + +```py +from typing import Callable, Never, TypeVarTuple +from typing_extensions import TypeForm +from ty_extensions import static_assert +from ty_extensions._internal import ConstraintSet + +def invalid_bounds[**P]() -> None: + static_assert(ConstraintSet.range(int, P, Callable[[int], None]) == ConstraintSet.never()) + static_assert(ConstraintSet.range(Callable[[int], None], P, int) == ConstraintSet.never()) + static_assert(ConstraintSet.range(object, P, object) == ConstraintSet.never()) + static_assert(ConstraintSet.range(Never, P, Never) == ConstraintSet.never()) +``` + +An ordinary TypeVar or ParamSpec component is not a complete parameter list. + +```py +def invalid_typevar_bounds[**P, **Q, T]() -> None: + static_assert(ConstraintSet.range(T, P, Callable[[int], None]) == ConstraintSet.never()) + static_assert(ConstraintSet.range(Callable[[int], None], P, T) == ConstraintSet.never()) + static_assert(ConstraintSet.range(Q.args, P, Callable[[int], None]) == ConstraintSet.never()) + static_assert(ConstraintSet.range(Callable[[int], None], P, Q.args) == ConstraintSet.never()) + static_assert(ConstraintSet.range(Q.kwargs, P, Callable[[int], None]) == ConstraintSet.never()) + static_assert(ConstraintSet.range(Callable[[int], None], P, Q.kwargs) == ConstraintSet.never()) +``` + +Allowing ParamSpec bounds must not admit them as ordinary TypeVar bounds. + +```py +def ordinary_subject[**P, T]() -> None: + ConstraintSet.range(P, T, object) # error: [invalid-type-form] "Bare ParamSpec `P`" + ConstraintSet.range(Never, T, P) # error: [invalid-type-form] "Bare ParamSpec `P`" +``` + +Bare TypeVarTuples remain invalid bounds, including when arguments are passed by keyword. + +```py +def typevartuple_bounds[**P, *Us]() -> None: + ConstraintSet.range(Us, P, Callable[[int], None]) # error: [invalid-type-form] "TypeVarTuple `Us`" + ConstraintSet.range(Callable[[int], None], P, Us) # error: [invalid-type-form] "TypeVarTuple `Us`" + ConstraintSet.range( + typevar=P, + upper_bound=Callable[[int], None], + lower_bound=Us, # error: [invalid-type-form] "TypeVarTuple `Us`" + ) + ConstraintSet.range( + upper_bound=Us, # error: [invalid-type-form] "TypeVarTuple `Us`" + lower_bound=Callable[[int], None], + typevar=P, + ) +``` + +The exception must not leak into other TypeForm calls, constructors, or nested type expressions. + +```py +def accepts_type_form(form: TypeForm[object]) -> None: ... +def invalid_forms[**P]() -> None: + ConstraintSet.lower_bound(Callable[[int], None], P) # error: [invalid-type-form] + ConstraintSet.upper_bound(P, Callable[[int], None]) # error: [invalid-type-form] + ConstraintSet.equality(P, Callable[[int], None]) # error: [invalid-type-form] + ConstraintSet.range(Callable[[int], None], list[P], Callable[[int], None]) # error: [invalid-type-arguments] + ConstraintSet.range(Callable[[P], None], P, Callable[[int], None]) # error: [invalid-type-form] + ConstraintSet.range(Callable[[int], None], P, Callable[..., P]) # error: [invalid-type-form] + accepts_type_form(P) # error: [invalid-type-form] +``` + +An attribute receiver must still reject bare ParamSpecs in unrelated TypeForm calls. + +```py +class Holder: + form: TypeForm[int] + +def receiver(form: TypeForm[object]) -> Holder: + return Holder() + +def invalid_receiver[**P]() -> None: + ConstraintSet.range(int, receiver(P).form, object) # error: [invalid-type-form] +``` + +ParamSpec components keep their ordinary bounds; bare TypeVarTuples remain invalid subjects. + +```py +Ts = TypeVarTuple("Ts") + +def legacy(value: tuple[*Ts]) -> None: + ConstraintSet.range(Callable[[int], None], Ts, Callable[[int], None]) # error: [invalid-type-form] + +def components_and_typevartuple[**P, *Us]() -> None: + reveal_type(ConstraintSet.range(tuple[int], P.args, tuple[object, ...])) # revealed: ConstraintSet[bool] + reveal_type(ConstraintSet.range(dict[str, object], P.kwargs, dict[str, object])) # revealed: ConstraintSet[bool] + ConstraintSet.range(Callable[[int], None], Us, Callable[[int], None]) # error: [invalid-type-form] +``` diff --git a/crates/ty_python_semantic/src/types/call/bind.rs b/crates/ty_python_semantic/src/types/call/bind.rs index 93bbea724afd23..c3050346ef4cba 100644 --- a/crates/ty_python_semantic/src/types/call/bind.rs +++ b/crates/ty_python_semantic/src/types/call/bind.rs @@ -2902,8 +2902,24 @@ impl<'db> Bindings<'db> { let Type::TypeVar(typevar) = typevar else { return; }; + let bounds = + if typevar.is_paramspec(db) && typevar.paramspec_attr(db).is_none() { + let normalize_bound = + |bound: Type<'db>| match bound.resolve_type_alias(db) { + Type::Callable(callable) => { + Some(Type::Callable(callable.into_paramspec_value(db))) + } + _ => None, + }; + normalize_bound(lower).zip(normalize_bound(upper)) + } else { + Some((lower, upper)) + }; let constraints = ConstraintSetBuilder::new(); let result = constraints.into_owned(|constraints| { + let Some((lower, upper)) = bounds else { + return ConstraintSet::from_bool(constraints, false); + }; ConstraintSet::constrain_typevar( db, env, diff --git a/crates/ty_python_semantic/src/types/callable.rs b/crates/ty_python_semantic/src/types/callable.rs index e2d6a031383a06..e923f247a67bd9 100644 --- a/crates/ty_python_semantic/src/types/callable.rs +++ b/crates/ty_python_semantic/src/types/callable.rs @@ -486,6 +486,21 @@ impl<'db> CallableType<'db> { CallableType::new(db, self.signatures(db), CallableTypeKind::Regular) } + /// Retain every parameter signature and its generic context, but erase return types + /// that do not participate in a `ParamSpec` specialization. + pub(crate) fn into_paramspec_value(self, db: &'db dyn Db) -> CallableType<'db> { + CallableType::new( + db, + CallableSignature::from_overloads( + self.signatures(db) + .iter() + .cloned() + .map(|signature| signature.with_return_type(Type::unknown())), + ), + CallableTypeKind::ParamSpecValue, + ) + } + /// Returns the reduced callable produced by partially applying selected overloads. pub(crate) fn partially_apply( db: &'db dyn Db, diff --git a/crates/ty_python_semantic/src/types/infer/builder.rs b/crates/ty_python_semantic/src/types/infer/builder.rs index 49e13caaa790c1..ecad06e0b41777 100644 --- a/crates/ty_python_semantic/src/types/infer/builder.rs +++ b/crates/ty_python_semantic/src/types/infer/builder.rs @@ -118,14 +118,14 @@ use crate::types::unpacker::{UnpackResult, fixed_sequence_elements}; use crate::types::{ BindingContext, BoundTypeVarInstance, CallDunderError, CallableBinding, CallableType, CallableTypes, ClassType, DynamicType, GeneratorTypeMode, InferenceFlags, - InternedConstraintSet, InternedType, IntersectionBuilder, IntersectionType, KnownClass, - KnownInstanceType, KnownUnion, LiteralValueType, LiteralValueTypeKind, MemberLookupPolicy, - ParamSpecAttrKind, Parameter, Parameters, ProgramEnvironment, SentinelInstance, Signature, - SpecialFormType, SubclassOfType, Type, TypeAliasType, TypeAndQualifiers, TypeContext, - TypeQualifiers, TypeVarBoundOrConstraints, TypeVarKind, TypeVarVariance, TypingModule, - UnionAccumulator, UnionBuilder, UnionType, any_over_type, binding_type, - extract_fixed_length_iterable_element_types, infer_complete_scope_types, infer_scope_types, - is_discarded_dict_key_assignment, todo_type, + InternedConstraintSet, InternedType, IntersectionBuilder, IntersectionType, + KnownBoundMethodType, KnownClass, KnownInstanceType, KnownUnion, LiteralValueType, + LiteralValueTypeKind, MemberLookupPolicy, ParamSpecAttrKind, Parameter, Parameters, + ProgramEnvironment, SentinelInstance, Signature, SpecialFormType, SubclassOfType, Type, + TypeAliasType, TypeAndQualifiers, TypeContext, TypeQualifiers, TypeVarBoundOrConstraints, + TypeVarKind, TypeVarVariance, TypingModule, UnionAccumulator, UnionBuilder, UnionType, + any_over_type, binding_type, extract_fixed_length_iterable_element_types, + infer_complete_scope_types, infer_scope_types, is_discarded_dict_key_assignment, todo_type, }; use crate::{AnalysisSettings, Db, DisplaySettings, FxIndexSet, FxOrderSet, SemanticModel}; use ty_python_core::definition::{ @@ -9402,10 +9402,36 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { &bindings, ); + let paramspec_range_subject = match callable_type { + Type::KnownBoundMethod(KnownBoundMethodType::ConstraintSetRange) => { + arguments.find_argument_value("typevar", 1) + } + _ => None, + }; + let bindings_result = self.infer_and_check_argument_types( ArgumentsIter::from_ast(arguments), &mut call_arguments, - &mut |builder, (_, expr, tcx)| builder.infer_expression(expr, tcx), + &mut |builder, (_, expr, tcx)| { + // The internal range constructor accepts a bare ParamSpec subject, but + // this must not allow nested uses such as `list[P]` or `Callable[..., P]`. + if paramspec_range_subject.is_some_and(|subject| std::ptr::eq(subject, expr)) + && is_dotted_name(expr) + { + let previously_allowed = builder + .context + .inference_flags + .replace(InferenceFlags::ALLOW_PARAMSPEC_TYPE_EXPR, true); + let ty = builder.infer_expression(expr, tcx); + builder.context.inference_flags.set( + InferenceFlags::ALLOW_PARAMSPEC_TYPE_EXPR, + previously_allowed, + ); + ty + } else { + builder.infer_expression(expr, tcx) + } + }, &mut bindings, call_expression_tcx, ); From 21540ff0544459d5e2d73463e5aadc478f411f2d Mon Sep 17 00:00:00 2001 From: Dhruv Manilawala Date: Tue, 25 Aug 2026 11:21:47 +0530 Subject: [PATCH 03/15] [ty] Support symbolic ParamSpec range bounds Permit bare ParamSpec endpoints only for a bare ParamSpec subject. Normalize unprefixed callable references to their bound ParamSpec while retaining callable-owned generic binders and Concatenate prefixes. Cover mixed and symbolic ranges, callable equivalence, and generic-binder preservation. --- .../mdtest/type_properties/constraints.md | 90 +++++++++++++++++++ .../ty_python_semantic/src/types/call/bind.rs | 15 ++++ .../src/types/infer/builder.rs | 23 ++++- 3 files changed, 125 insertions(+), 3 deletions(-) diff --git a/crates/ty_python_semantic/resources/mdtest/type_properties/constraints.md b/crates/ty_python_semantic/resources/mdtest/type_properties/constraints.md index 49e364bb609fba..40f3e9d7a07b00 100644 --- a/crates/ty_python_semantic/resources/mdtest/type_properties/constraints.md +++ b/crates/ty_python_semantic/resources/mdtest/type_properties/constraints.md @@ -1201,6 +1201,16 @@ def signature[**P]() -> None: reveal_type(constraints.with_detailed_display()) ``` +Generic callable bounds keep their own ParamSpec binder. + +```py +def callback[**Q](*args: Q.args, **kwargs: Q.kwargs) -> None: ... +def generic_signature[**P]() -> None: + constraints = ConstraintSet.range(RegularCallableTypeOf[callback], P, RegularCallableTypeOf[callback]) + # revealed: ConstraintSet[(P@generic_signature = (**Q@callback))] + reveal_type(constraints.with_detailed_display()) +``` + ## ParamSpec A ParamSpec range is `lower_parameters ≤ P ≤ upper_parameters`; callable returns are ignored. @@ -1319,6 +1329,86 @@ def incompatible[**P]() -> None: static_assert(incomparable == ConstraintSet.never()) ``` +### Symbolic bounds + +Another ParamSpec can bind both endpoints, requiring the parameter lists to be equal. + +```py +from typing import Any, Callable +from ty_extensions import static_assert +from ty_extensions._internal import ConstraintSet, is_constraint_set_assignable_to + +def equality[**P, **Q]() -> None: + constraints = ConstraintSet.range(Q, P, Q) + expected = is_constraint_set_assignable_to(Callable[P, Any], Callable[Q, Any]) + static_assert(constraints == expected) + static_assert(constraints == ConstraintSet.range(P, Q, P)) +``` + +Each endpoint is retained; a self-bound (`P ≤ P`) leaves that side unconstrained. + +```py +def symbolic_lower[**P, **Q]() -> None: + constraints = ConstraintSet.range(Q, P, Callable[[int], None]) + lower = ConstraintSet.range(Q, P, P) + upper = ConstraintSet.range(P, P, Callable[[int], None]) + static_assert(constraints == (lower & upper)) + static_assert(constraints != lower) + static_assert(constraints != upper) +``` + +Symbolic upper bounds likewise retain their concrete lower bound. + +```py +def symbolic_upper[**P, **Q]() -> None: + constraints = ConstraintSet.range(Callable[[int], None], P, Q) + lower = ConstraintSet.range(Callable[[int], None], P, P) + upper = ConstraintSet.range(P, P, Q) + static_assert(constraints == (lower & upper)) + static_assert(constraints != lower) + static_assert(constraints != upper) +``` + +Three ParamSpecs form a two-sided range, independent of conjunction order. + +```py +def symbolic_range[**P, **Q, **R]() -> None: + constraints = ConstraintSet.range(Q, P, R) + lower = ConstraintSet.range(Q, P, P) + upper = ConstraintSet.range(P, P, R) + static_assert(constraints == (lower & upper)) + static_assert(constraints == (upper & lower)) + static_assert(constraints != lower) + static_assert(constraints != upper) +``` + +### Symbolic callable bounds + +An unprefixed callable bound describes the same parameter list as its bare ParamSpec. + +```py +from typing import Any, Callable, Concatenate +from ty_extensions import static_assert +from ty_extensions._internal import ConstraintSet, is_constraint_set_assignable_to + +def unprefixed[**P, **Q]() -> None: + constraints = ConstraintSet.range(Callable[Q, int], P, Callable[Q, str]) + static_assert(constraints == ConstraintSet.range(Q, P, Q)) +``` + +A `Concatenate` bound preserves its prefix and symbolic tail while erasing the return. + +```py +def prefixed[**P, **Q]() -> None: + constraints = ConstraintSet.range(Callable[Concatenate[int, Q], int], P, Callable[Concatenate[int, Q], str]) + expected = is_constraint_set_assignable_to(Callable[Concatenate[int, Q], int], Callable[P, Any]) + expected &= is_constraint_set_assignable_to(Callable[P, Any], Callable[Concatenate[int, Q], str]) + static_assert(constraints == expected) + static_assert(constraints != ConstraintSet.range(Q, P, Q)) + different_prefix = ConstraintSet.range(Callable[Concatenate[str, Q], None], P, Callable[Concatenate[str, Q], None]) + static_assert(constraints != different_prefix) +``` + ### Signature preservation Named parameters accept positional-only calls; the reverse range is invalid. diff --git a/crates/ty_python_semantic/src/types/call/bind.rs b/crates/ty_python_semantic/src/types/call/bind.rs index c3050346ef4cba..ab97069d879e88 100644 --- a/crates/ty_python_semantic/src/types/call/bind.rs +++ b/crates/ty_python_semantic/src/types/call/bind.rs @@ -2906,9 +2906,24 @@ impl<'db> Bindings<'db> { if typevar.is_paramspec(db) && typevar.paramspec_attr(db).is_none() { let normalize_bound = |bound: Type<'db>| match bound.resolve_type_alias(db) { + Type::Callable(callable) + if let [signature] = + callable.signatures(db).overloads.as_slice() + && signature.generic_context.is_none() + && let Some(paramspec) = + signature.parameters().as_paramspec() => + { + Some(Type::TypeVar(paramspec)) + } Type::Callable(callable) => { Some(Type::Callable(callable.into_paramspec_value(db))) } + Type::TypeVar(bound) + if bound.is_paramspec(db) + && bound.paramspec_attr(db).is_none() => + { + Some(Type::TypeVar(bound)) + } _ => None, }; normalize_bound(lower).zip(normalize_bound(upper)) diff --git a/crates/ty_python_semantic/src/types/infer/builder.rs b/crates/ty_python_semantic/src/types/infer/builder.rs index ecad06e0b41777..1e8355a9749c50 100644 --- a/crates/ty_python_semantic/src/types/infer/builder.rs +++ b/crates/ty_python_semantic/src/types/infer/builder.rs @@ -9408,14 +9408,31 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { } _ => None, }; + let paramspec_range_subject_is_bare = paramspec_range_subject + .filter(|subject| is_dotted_name(subject)) + .is_some_and(|subject| { + let mut speculative = self.speculate_without_diagnostics(); + speculative.expression_cache = None; + match speculative.infer_expression(subject, TypeContext::default()) { + Type::KnownInstance(KnownInstanceType::TypeVar(typevar)) => { + typevar.is_paramspec(db) + } + Type::TypeVar(typevar) => { + typevar.is_paramspec(db) && typevar.paramspec_attr(db).is_none() + } + _ => false, + } + }); let bindings_result = self.infer_and_check_argument_types( ArgumentsIter::from_ast(arguments), &mut call_arguments, &mut |builder, (_, expr, tcx)| { - // The internal range constructor accepts a bare ParamSpec subject, but - // this must not allow nested uses such as `list[P]` or `Callable[..., P]`. - if paramspec_range_subject.is_some_and(|subject| std::ptr::eq(subject, expr)) + // A ParamSpec range can refer to other ParamSpecs. Limit the exception to + // dotted names so nested types and calls in attribute receivers keep + // their ordinary validation. + if (paramspec_range_subject_is_bare + || paramspec_range_subject.is_some_and(|subject| std::ptr::eq(subject, expr))) && is_dotted_name(expr) { let previously_allowed = builder From 059f98a5b1e0d64adade3999ef98f29871a04abc Mon Sep 17 00:00:00 2001 From: Dhruv Manilawala Date: Tue, 25 Aug 2026 14:08:07 +0530 Subject: [PATCH 04/15] [ty] Keep constraint range tests positional Remove duplicate keyword-call variants while retaining positional lower- and upper-bound TypeVarTuple rejection coverage. --- .../resources/mdtest/type_properties/constraints.md | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/crates/ty_python_semantic/resources/mdtest/type_properties/constraints.md b/crates/ty_python_semantic/resources/mdtest/type_properties/constraints.md index 40f3e9d7a07b00..fc88a4cd1e58f3 100644 --- a/crates/ty_python_semantic/resources/mdtest/type_properties/constraints.md +++ b/crates/ty_python_semantic/resources/mdtest/type_properties/constraints.md @@ -1579,22 +1579,12 @@ def ordinary_subject[**P, T]() -> None: ConstraintSet.range(Never, T, P) # error: [invalid-type-form] "Bare ParamSpec `P`" ``` -Bare TypeVarTuples remain invalid bounds, including when arguments are passed by keyword. +Bare TypeVarTuples remain invalid bounds. ```py def typevartuple_bounds[**P, *Us]() -> None: ConstraintSet.range(Us, P, Callable[[int], None]) # error: [invalid-type-form] "TypeVarTuple `Us`" ConstraintSet.range(Callable[[int], None], P, Us) # error: [invalid-type-form] "TypeVarTuple `Us`" - ConstraintSet.range( - typevar=P, - upper_bound=Callable[[int], None], - lower_bound=Us, # error: [invalid-type-form] "TypeVarTuple `Us`" - ) - ConstraintSet.range( - upper_bound=Us, # error: [invalid-type-form] "TypeVarTuple `Us`" - lower_bound=Callable[[int], None], - typevar=P, - ) ``` The exception must not leak into other TypeForm calls, constructors, or nested type expressions. From 3261ff04c64656fc8c3f6e4d42e4c672b3409352 Mon Sep 17 00:00:00 2001 From: Dhruv Manilawala Date: Tue, 25 Aug 2026 14:12:03 +0530 Subject: [PATCH 05/15] [ty] Restore positional-only constraint range parameters Revert the synthetic range signature change at review request. Keyword-call support and alignment with the stub are outside this ParamSpec change. --- crates/ty_python_semantic/src/types/method.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/ty_python_semantic/src/types/method.rs b/crates/ty_python_semantic/src/types/method.rs index 050843e3de4adb..05df72fd936ea4 100644 --- a/crates/ty_python_semantic/src/types/method.rs +++ b/crates/ty_python_semantic/src/types/method.rs @@ -533,11 +533,11 @@ impl<'db> KnownBoundMethodType<'db> { KnownBoundMethodType::ConstraintSetRange => { Either::Right(std::iter::once(Signature::new( Parameters::standard([ - Parameter::positional_or_keyword(Name::new_static("lower_bound")) + Parameter::positional_only(Some(Name::new_static("lower_bound"))) .with_annotated_type(object_type_form()), - Parameter::positional_or_keyword(Name::new_static("typevar")) + Parameter::positional_only(Some(Name::new_static("typevar"))) .with_annotated_type(object_type_form()), - Parameter::positional_or_keyword(Name::new_static("upper_bound")) + Parameter::positional_only(Some(Name::new_static("upper_bound"))) .with_annotated_type(object_type_form()), ]), KnownClass::ConstraintSet.to_instance(db, env), From 88002e457850d840290fb72c98c4fd3907267ad4 Mon Sep 17 00:00:00 2001 From: Dhruv Manilawala Date: Tue, 25 Aug 2026 15:27:23 +0530 Subject: [PATCH 06/15] [ty] Support ParamSpecs in remaining constraint constructors --- .../mdtest/type_properties/constraints.md | 140 +++++++++++++- .../ty_python_semantic/src/types/call/bind.rs | 181 +++++++----------- .../src/types/infer/builder.rs | 22 ++- 3 files changed, 212 insertions(+), 131 deletions(-) diff --git a/crates/ty_python_semantic/resources/mdtest/type_properties/constraints.md b/crates/ty_python_semantic/resources/mdtest/type_properties/constraints.md index fc88a4cd1e58f3..5c3f6153cb8525 100644 --- a/crates/ty_python_semantic/resources/mdtest/type_properties/constraints.md +++ b/crates/ty_python_semantic/resources/mdtest/type_properties/constraints.md @@ -138,6 +138,16 @@ def _[T]() -> None: static_assert(ConstraintSet.lower_bound(int, T) == expected) ``` +Ordinary TypeVar bounds retain callable returns. + +```py +from typing import Callable + +def callable_bound[T]() -> None: + constraints = ConstraintSet.lower_bound(Callable[[int], int], T) + static_assert(constraints != ConstraintSet.lower_bound(Callable[[int], str], T)) +``` + ### Upper bound An upper-bound constraint requires the type variable to be a subtype of its bound without providing @@ -152,6 +162,16 @@ def _[T]() -> None: static_assert(ConstraintSet.upper_bound(T, int) == expected) ``` +Upper bounds likewise retain ordinary callable returns. + +```py +from typing import Callable + +def callable_bound[T]() -> None: + constraints = ConstraintSet.upper_bound(T, Callable[[int], int]) + static_assert(constraints != ConstraintSet.upper_bound(T, Callable[[int], str])) +``` + Unlike an explicit two-sided range, an upper-bound constraint does not supply `Never` as lower-bound inference evidence. @@ -183,6 +203,16 @@ def _[T]() -> None: reveal_type(equality.solutions_for(T, inferable=tuple[T])) ``` +Equality of ordinary types also includes callable returns. + +```py +from typing import Callable + +def callable_bound[T]() -> None: + constraints = ConstraintSet.equality(T, Callable[[int], int]) + static_assert(constraints != ConstraintSet.equality(T, Callable[[int], str])) +``` + ### Negated range A _negated range_ constraint is the opposite of a range constraint: it requires the typevar to _not_ @@ -1174,6 +1204,7 @@ out all of the different kinds of constraints described above. Here we just test exists, and provides more detail than otherwise. ```py +from ty_extensions import static_assert from ty_extensions._internal import ConstraintSet, RegularCallableTypeOf class Super: ... @@ -1199,6 +1230,7 @@ def signature[**P]() -> None: constraints = ConstraintSet.range(RegularCallableTypeOf[complete], P, RegularCallableTypeOf[complete]) # revealed: ConstraintSet[(P@signature = (value: int, /, text: str = "", *args: float, flag: bool = False, **kwargs: bytes))] reveal_type(constraints.with_detailed_display()) + static_assert(ConstraintSet.equality(P, RegularCallableTypeOf[complete]) == constraints) ``` Generic callable bounds keep their own ParamSpec binder. @@ -1209,15 +1241,17 @@ def generic_signature[**P]() -> None: constraints = ConstraintSet.range(RegularCallableTypeOf[callback], P, RegularCallableTypeOf[callback]) # revealed: ConstraintSet[(P@generic_signature = (**Q@callback))] reveal_type(constraints.with_detailed_display()) + # revealed: ConstraintSet[((**Q@callback) ≤ P@generic_signature)] + reveal_type(ConstraintSet.lower_bound(RegularCallableTypeOf[callback], P).with_detailed_display()) ``` ## ParamSpec -A ParamSpec range is `lower_parameters ≤ P ≤ upper_parameters`; callable returns are ignored. +A ParamSpec constraint describes parameter lists; callable returns are ignored. -### Range construction +### Construction -A legacy ParamSpec bound by a generic callable is valid as the subject of a nontrivial range. +A bound legacy ParamSpec works with every constructor; equality supplies both endpoints. ```py from typing import Callable, ParamSpec @@ -1231,6 +1265,15 @@ def legacy(callback: Callable[P, None]) -> None: reveal_type(constraints) # revealed: ConstraintSet[bool] different_returns = ConstraintSet.range(Callable[[int, str], int], P, Callable[[int, str], str]) static_assert(constraints == different_returns) + lower = ConstraintSet.lower_bound(Callable[[int, str], int], P) + upper = ConstraintSet.upper_bound(P, Callable[[int, str], str]) + exact = ConstraintSet.equality(P, Callable[[int, str], bytes]) + static_assert(lower == ConstraintSet.range(Callable[[int, str], None], P, P)) + static_assert(upper == ConstraintSet.range(P, P, Callable[[int, str], None])) + static_assert(exact == constraints) + static_assert(exact == (lower & upper)) + static_assert(exact != lower) + static_assert(exact != upper) ``` An empty parameter list is an exact bound, distinct from a one-parameter list. @@ -1240,6 +1283,16 @@ def empty[**P]() -> None: constraints = ConstraintSet.range(Callable[[], None], P, Callable[[], None]) reveal_type(constraints) # revealed: ConstraintSet[bool] static_assert(constraints != ConstraintSet.range(Callable[[int], None], P, Callable[[int], None])) + static_assert(ConstraintSet.equality(P, Callable[[], int]) == constraints) +``` + +An alias of a known constructor retains its ParamSpec argument rules. + +```py +def aliased_constructor[**P]() -> None: + equals = ConstraintSet.equality + constraints = equals(P, Callable[[int], None]) + static_assert(constraints == ConstraintSet.range(Callable[[int], None], P, Callable[[int], None])) ``` ### Qualified subjects @@ -1258,12 +1311,18 @@ P = ParamSpec("P") ```py from typing import Callable +from ty_extensions import static_assert from ty_extensions._internal import ConstraintSet import params def qualified(callback: Callable[params.P, None]) -> None: constraints = ConstraintSet.range(Callable[[int], None], params.P, Callable[[int], None]) reveal_type(constraints) # revealed: ConstraintSet[bool] + lower = ConstraintSet.lower_bound(Callable[[int], None], params.P) + upper = ConstraintSet.upper_bound(params.P, Callable[[int], None]) + static_assert(lower == ConstraintSet.range(Callable[[int], None], params.P, params.P)) + static_assert(upper == ConstraintSet.range(params.P, params.P, Callable[[int], None])) + static_assert(ConstraintSet.equality(params.P, Callable[[int], None]) == constraints) ``` ### Callable aliases @@ -1294,6 +1353,7 @@ def concatenate[**P]() -> None: reveal_type(constraints) # revealed: ConstraintSet[bool] expected = ConstraintSet.range(Callable[[int, str, bool], None], P, Callable[[int, str, bool], None]) static_assert(constraints == expected) + static_assert(ConstraintSet.equality(P, Prefixed[[bool], bytes]) == expected) ``` ### Two-sided bounds @@ -1317,6 +1377,11 @@ def two_sided[**P]() -> None: reveal_type(constraints) # revealed: ConstraintSet[bool] static_assert(constraints != ConstraintSet.range(Callable[[Super], None], P, Callable[[Super], None])) static_assert(constraints != ConstraintSet.range(Callable[[Sub], None], P, Callable[[Sub], None])) + lower = ConstraintSet.lower_bound(Callable[[Super], int], P) + upper = ConstraintSet.upper_bound(P, Callable[[Sub], str]) + static_assert(constraints == (lower & upper)) + static_assert(constraints != lower) + static_assert(constraints != upper) ``` Inverted or incomparable bounds are unsatisfiable. @@ -1325,6 +1390,9 @@ Inverted or incomparable bounds are unsatisfiable. def incompatible[**P]() -> None: inverted = ConstraintSet.range(Callable[[Sub], None], P, Callable[[Super], None]) static_assert(inverted == ConstraintSet.never()) + lower = ConstraintSet.lower_bound(Callable[[Sub], None], P) + upper = ConstraintSet.upper_bound(P, Callable[[Super], None]) + static_assert((lower & upper) == ConstraintSet.never()) incomparable = ConstraintSet.range(Callable[[Base], None], P, Callable[[Unrelated], None]) static_assert(incomparable == ConstraintSet.never()) ``` @@ -1343,6 +1411,8 @@ def equality[**P, **Q]() -> None: expected = is_constraint_set_assignable_to(Callable[P, Any], Callable[Q, Any]) static_assert(constraints == expected) static_assert(constraints == ConstraintSet.range(P, Q, P)) + static_assert(ConstraintSet.equality(P, Q) == constraints) + static_assert(ConstraintSet.equality(Q, P) == constraints) ``` Each endpoint is retained; a self-bound (`P ≤ P`) leaves that side unconstrained. @@ -1351,6 +1421,7 @@ Each endpoint is retained; a self-bound (`P ≤ P`) leaves that side unconstrain def symbolic_lower[**P, **Q]() -> None: constraints = ConstraintSet.range(Q, P, Callable[[int], None]) lower = ConstraintSet.range(Q, P, P) + static_assert(ConstraintSet.lower_bound(Q, P) == lower) upper = ConstraintSet.range(P, P, Callable[[int], None]) static_assert(constraints == (lower & upper)) static_assert(constraints != lower) @@ -1364,6 +1435,7 @@ def symbolic_upper[**P, **Q]() -> None: constraints = ConstraintSet.range(Callable[[int], None], P, Q) lower = ConstraintSet.range(Callable[[int], None], P, P) upper = ConstraintSet.range(P, P, Q) + static_assert(ConstraintSet.upper_bound(P, Q) == upper) static_assert(constraints == (lower & upper)) static_assert(constraints != lower) static_assert(constraints != upper) @@ -1394,6 +1466,7 @@ from ty_extensions._internal import ConstraintSet, is_constraint_set_assignable_ def unprefixed[**P, **Q]() -> None: constraints = ConstraintSet.range(Callable[Q, int], P, Callable[Q, str]) static_assert(constraints == ConstraintSet.range(Q, P, Q)) + static_assert(ConstraintSet.lower_bound(Callable[Q, bytes], P) == ConstraintSet.range(Q, P, P)) ``` A `Concatenate` bound preserves its prefix and symbolic tail while erasing the return. @@ -1407,6 +1480,8 @@ def prefixed[**P, **Q]() -> None: static_assert(constraints != ConstraintSet.range(Q, P, Q)) different_prefix = ConstraintSet.range(Callable[Concatenate[str, Q], None], P, Callable[Concatenate[str, Q], None]) static_assert(constraints != different_prefix) + upper = ConstraintSet.upper_bound(P, Callable[Concatenate[int, Q], bytes]) + static_assert(upper == ConstraintSet.range(P, P, Callable[Concatenate[int, Q], None])) ``` ### Signature preservation @@ -1494,6 +1569,7 @@ def overloads[**P]() -> None: static_assert(constraints == ConstraintSet.range(RegularCallableTypeOf[overloaded], P, RegularCallableTypeOf[overloaded])) static_assert(constraints != ConstraintSet.range(Callable[[int], None], P, Callable[[int], None])) static_assert(constraints != ConstraintSet.range(RegularCallableTypeOf[keyword], P, RegularCallableTypeOf[keyword])) + static_assert(ConstraintSet.equality(P, RegularCallableTypeOf[swapped_returns]) == constraints) ``` An overloaded lower bound can satisfy a single signature; an overloaded upper bound requires both. @@ -1528,6 +1604,11 @@ from ty_extensions._internal import ConstraintSet def gradual[**P]() -> None: ellipsis = ConstraintSet.range(Callable[..., int], P, Callable[..., str]) reveal_type(ellipsis.with_detailed_display()) # revealed: ConstraintSet[(P@gradual = (...))] + static_assert(ConstraintSet.equality(P, Callable[..., bytes]) == ellipsis) + # revealed: ConstraintSet[((...) ≤ P@gradual)] + reveal_type(ConstraintSet.lower_bound(Callable[..., int], P).with_detailed_display()) + # revealed: ConstraintSet[(P@gradual ≤ (...))] + reveal_type(ConstraintSet.upper_bound(P, Callable[..., str]).with_detailed_display()) any_parameter = ConstraintSet.range(Callable[[Any], int], P, Callable[[Any], str]) reveal_type(any_parameter.with_detailed_display()) # revealed: ConstraintSet[(P@gradual = (Any, /))] ``` @@ -1542,6 +1623,20 @@ def empty[**P]() -> None: static_assert(ConstraintSet.range(Callable[[], None], P, Callable[[Any], None]) == ConstraintSet.never()) ``` +Missing endpoints differ from explicit gradual evidence, without requiring solution extraction. + +```py +def missing_bounds[**P]() -> None: + # revealed: ConstraintSet[((int, /) ≤ P@missing_bounds)] + reveal_type(ConstraintSet.lower_bound(Callable[[int], None], P).with_detailed_display()) + # revealed: ConstraintSet[((int, /) ≤ P@missing_bounds ≤ (...))] + reveal_type(ConstraintSet.range(Callable[[int], None], P, Callable[..., None]).with_detailed_display()) + # revealed: ConstraintSet[(P@missing_bounds ≤ (int, /))] + reveal_type(ConstraintSet.upper_bound(P, Callable[[int], None]).with_detailed_display()) + # revealed: ConstraintSet[((...) ≤ P@missing_bounds ≤ (int, /))] + reveal_type(ConstraintSet.range(Callable[..., None], P, Callable[[int], None]).with_detailed_display()) +``` + ### Invalid forms and preservation controls An ordinary type in either bound makes a ParamSpec range unsatisfiable. @@ -1557,6 +1652,9 @@ def invalid_bounds[**P]() -> None: static_assert(ConstraintSet.range(Callable[[int], None], P, int) == ConstraintSet.never()) static_assert(ConstraintSet.range(object, P, object) == ConstraintSet.never()) static_assert(ConstraintSet.range(Never, P, Never) == ConstraintSet.never()) + static_assert(ConstraintSet.lower_bound(int, P) == ConstraintSet.never()) + static_assert(ConstraintSet.upper_bound(P, object) == ConstraintSet.never()) + static_assert(ConstraintSet.equality(P, Never) == ConstraintSet.never()) ``` An ordinary TypeVar or ParamSpec component is not a complete parameter list. @@ -1569,6 +1667,9 @@ def invalid_typevar_bounds[**P, **Q, T]() -> None: static_assert(ConstraintSet.range(Callable[[int], None], P, Q.args) == ConstraintSet.never()) static_assert(ConstraintSet.range(Q.kwargs, P, Callable[[int], None]) == ConstraintSet.never()) static_assert(ConstraintSet.range(Callable[[int], None], P, Q.kwargs) == ConstraintSet.never()) + static_assert(ConstraintSet.lower_bound(T, P) == ConstraintSet.never()) + static_assert(ConstraintSet.upper_bound(P, Q.kwargs) == ConstraintSet.never()) + static_assert(ConstraintSet.equality(P, Q.args) == ConstraintSet.never()) ``` Allowing ParamSpec bounds must not admit them as ordinary TypeVar bounds. @@ -1577,6 +1678,9 @@ Allowing ParamSpec bounds must not admit them as ordinary TypeVar bounds. def ordinary_subject[**P, T]() -> None: ConstraintSet.range(P, T, object) # error: [invalid-type-form] "Bare ParamSpec `P`" ConstraintSet.range(Never, T, P) # error: [invalid-type-form] "Bare ParamSpec `P`" + ConstraintSet.lower_bound(P, T) # error: [invalid-type-form] "Bare ParamSpec `P`" + ConstraintSet.upper_bound(T, P) # error: [invalid-type-form] "Bare ParamSpec `P`" + ConstraintSet.equality(T, P) # error: [invalid-type-form] "Bare ParamSpec `P`" ``` Bare TypeVarTuples remain invalid bounds. @@ -1585,19 +1689,23 @@ Bare TypeVarTuples remain invalid bounds. def typevartuple_bounds[**P, *Us]() -> None: ConstraintSet.range(Us, P, Callable[[int], None]) # error: [invalid-type-form] "TypeVarTuple `Us`" ConstraintSet.range(Callable[[int], None], P, Us) # error: [invalid-type-form] "TypeVarTuple `Us`" + ConstraintSet.lower_bound(Us, P) # error: [invalid-type-form] "TypeVarTuple `Us`" + ConstraintSet.upper_bound(P, Us) # error: [invalid-type-form] "TypeVarTuple `Us`" + ConstraintSet.equality(P, Us) # error: [invalid-type-form] "TypeVarTuple `Us`" ``` -The exception must not leak into other TypeForm calls, constructors, or nested type expressions. +Nested type expressions and unrelated TypeForm calls keep their ordinary validation. ```py def accepts_type_form(form: TypeForm[object]) -> None: ... def invalid_forms[**P]() -> None: - ConstraintSet.lower_bound(Callable[[int], None], P) # error: [invalid-type-form] - ConstraintSet.upper_bound(P, Callable[[int], None]) # error: [invalid-type-form] - ConstraintSet.equality(P, Callable[[int], None]) # error: [invalid-type-form] ConstraintSet.range(Callable[[int], None], list[P], Callable[[int], None]) # error: [invalid-type-arguments] ConstraintSet.range(Callable[[P], None], P, Callable[[int], None]) # error: [invalid-type-form] ConstraintSet.range(Callable[[int], None], P, Callable[..., P]) # error: [invalid-type-form] + ConstraintSet.lower_bound(Callable[[P], None], P) # error: [invalid-type-form] + ConstraintSet.upper_bound(P, Callable[..., P]) # error: [invalid-type-form] + ConstraintSet.equality(list[P], Callable[[int], None]) # error: [invalid-type-arguments] + ConstraintSet.equality(P, Callable[[int], None]) accepts_type_form(P) # error: [invalid-type-form] ``` @@ -1610,8 +1718,11 @@ class Holder: def receiver(form: TypeForm[object]) -> Holder: return Holder() -def invalid_receiver[**P]() -> None: +def invalid_receiver[**P, **Q]() -> None: ConstraintSet.range(int, receiver(P).form, object) # error: [invalid-type-form] + ConstraintSet.lower_bound(Callable[[int], None], receiver(P).form) # error: [invalid-type-form] "Bare ParamSpec `P`" + ConstraintSet.upper_bound(receiver(P).form, Callable[[int], None]) # error: [invalid-type-form] "Bare ParamSpec `P`" + ConstraintSet.equality(P, receiver(Q).form) # error: [invalid-type-form] "Bare ParamSpec `Q`" ``` ParamSpec components keep their ordinary bounds; bare TypeVarTuples remain invalid subjects. @@ -1623,7 +1734,16 @@ def legacy(value: tuple[*Ts]) -> None: ConstraintSet.range(Callable[[int], None], Ts, Callable[[int], None]) # error: [invalid-type-form] def components_and_typevartuple[**P, *Us]() -> None: - reveal_type(ConstraintSet.range(tuple[int], P.args, tuple[object, ...])) # revealed: ConstraintSet[bool] - reveal_type(ConstraintSet.range(dict[str, object], P.kwargs, dict[str, object])) # revealed: ConstraintSet[bool] + args = ConstraintSet.range(tuple[int], P.args, tuple[object, ...]) + kwargs = ConstraintSet.range(dict[str, object], P.kwargs, dict[str, object]) + reveal_type(args) # revealed: ConstraintSet[bool] + reveal_type(kwargs) # revealed: ConstraintSet[bool] + lower = ConstraintSet.lower_bound(tuple[int], P.args) + upper = ConstraintSet.upper_bound(P.args, tuple[object, ...]) + static_assert((lower & upper) == args) + static_assert(ConstraintSet.equality(P.kwargs, dict[str, object]) == kwargs) ConstraintSet.range(Callable[[int], None], Us, Callable[[int], None]) # error: [invalid-type-form] + ConstraintSet.lower_bound(Callable[[], None], Us) # error: [invalid-type-form] "TypeVarTuple `Us`" + ConstraintSet.upper_bound(Us, Callable[[], None]) # error: [invalid-type-form] "TypeVarTuple `Us`" + ConstraintSet.equality(Us, Callable[[], None]) # error: [invalid-type-form] "TypeVarTuple `Us`" ``` diff --git a/crates/ty_python_semantic/src/types/call/bind.rs b/crates/ty_python_semantic/src/types/call/bind.rs index ab97069d879e88..ac97064abdaf61 100644 --- a/crates/ty_python_semantic/src/types/call/bind.rs +++ b/crates/ty_python_semantic/src/types/call/bind.rs @@ -34,8 +34,8 @@ use crate::types::ProgramEnvironment; use crate::types::call::arguments::{CallArgumentTypes, Expansion, is_expandable_type}; use crate::types::callable::CallableTypeKind; use crate::types::constraints::{ - ConstraintSet, ConstraintSetBuilder, PathBound, PathBoundSolution, PathBounds, SolutionPaths, - Solutions, + Constraint, ConstraintSet, ConstraintSetBuilder, PathBound, PathBoundSolution, PathBounds, + SolutionPaths, Solutions, }; use crate::types::context::LintDiagnosticGuardBuilder; use crate::types::dedicated::pydantic::{self, ConfigBoolean}; @@ -2815,133 +2815,88 @@ impl<'db> Bindings<'db> { } }, - Type::KnownBoundMethod(KnownBoundMethodType::ConstraintSetLowerBound) => { - let [Some(lower), Some(typevar)] = overload.parameter_types() else { - return; - }; - let lower = lower.project_type_form(db, env); - let typevar = typevar.project_type_form(db, env); - let Type::TypeVar(typevar) = typevar else { - return; - }; - let constraints = ConstraintSetBuilder::new(); - let result = constraints.into_owned(|constraints| { - ConstraintSet::constrain_typevar_lower_bound( - db, - env, - constraints, - typevar, - lower, + Type::KnownBoundMethod( + method @ (KnownBoundMethodType::ConstraintSetLowerBound + | KnownBoundMethodType::ConstraintSetUpperBound + | KnownBoundMethodType::ConstraintSetEquality + | KnownBoundMethodType::ConstraintSetRange), + ) => { + let typevar = match (method, overload.parameter_types()) { + ( + KnownBoundMethodType::ConstraintSetLowerBound, + [Some(_), Some(typevar)], ) - }); - let tracked = InternedConstraintSet::new(db, result); - overload.set_return_type(Type::KnownInstance( - KnownInstanceType::ConstraintSet(tracked), - )); - } - - Type::KnownBoundMethod(KnownBoundMethodType::ConstraintSetUpperBound) => { - let [Some(typevar), Some(upper)] = overload.parameter_types() else { - return; - }; - let typevar = typevar.project_type_form(db, env); - let upper = upper.project_type_form(db, env); - let Type::TypeVar(typevar) = typevar else { - return; - }; - let constraints = ConstraintSetBuilder::new(); - let result = constraints.into_owned(|constraints| { - ConstraintSet::constrain_typevar_upper_bound( - db, - env, - constraints, - typevar, - upper, + | ( + KnownBoundMethodType::ConstraintSetRange, + [Some(_), Some(typevar), Some(_)], ) - }); - let tracked = InternedConstraintSet::new(db, result); - overload.set_return_type(Type::KnownInstance( - KnownInstanceType::ConstraintSet(tracked), - )); - } - - Type::KnownBoundMethod(KnownBoundMethodType::ConstraintSetEquality) => { - let [Some(typevar), Some(value)] = overload.parameter_types() else { - return; + | ( + KnownBoundMethodType::ConstraintSetUpperBound + | KnownBoundMethodType::ConstraintSetEquality, + [Some(typevar), Some(_)], + ) => typevar.project_type_form(db, env), + _ => return, }; - let typevar = typevar.project_type_form(db, env); - let value = value.project_type_form(db, env); let Type::TypeVar(typevar) = typevar else { return; }; - let constraints = ConstraintSetBuilder::new(); - let result = constraints.into_owned(|constraints| { - ConstraintSet::constrain_typevar( - db, - env, - constraints, - typevar, - value, - value, - ) - }); - let tracked = InternedConstraintSet::new(db, result); - overload.set_return_type(Type::KnownInstance( - KnownInstanceType::ConstraintSet(tracked), - )); - } - - Type::KnownBoundMethod(KnownBoundMethodType::ConstraintSetRange) => { - let [Some(lower), Some(typevar), Some(upper)] = overload.parameter_types() - else { - return; + let normalize_bound = |bound: Type<'db>| { + let bound = bound.project_type_form(db, env); + if !typevar.is_paramspec(db) || typevar.paramspec_attr(db).is_some() { + return Some(bound); + } + match bound.resolve_type_alias(db) { + Type::Callable(callable) + if let [signature] = + callable.signatures(db).overloads.as_slice() + && signature.generic_context.is_none() + && let Some(paramspec) = + signature.parameters().as_paramspec() => + { + Some(Type::TypeVar(paramspec)) + } + Type::Callable(callable) => { + Some(Type::Callable(callable.into_paramspec_value(db))) + } + Type::TypeVar(bound) + if bound.is_paramspec(db) + && bound.paramspec_attr(db).is_none() => + { + Some(Type::TypeVar(bound)) + } + _ => None, + } }; - let lower = lower.project_type_form(db, env); - let typevar = typevar.project_type_form(db, env); - let upper = upper.project_type_form(db, env); - let Type::TypeVar(typevar) = typevar else { - return; + // A failed normalization rejects a supplied bound; it is not a missing + // endpoint. Preserve absent sides separately inside each successful pair. + let bounds = match (method, overload.parameter_types()) { + (KnownBoundMethodType::ConstraintSetLowerBound, [Some(lower), _]) => { + normalize_bound(*lower).map(|lower| (Some(lower), None)) + } + (KnownBoundMethodType::ConstraintSetUpperBound, [_, Some(upper)]) => { + normalize_bound(*upper).map(|upper| (None, Some(upper))) + } + (KnownBoundMethodType::ConstraintSetEquality, [_, Some(value)]) => { + normalize_bound(*value).map(|value| (Some(value), Some(value))) + } + ( + KnownBoundMethodType::ConstraintSetRange, + [Some(lower), _, Some(upper)], + ) => normalize_bound(*lower) + .zip(normalize_bound(*upper)) + .map(|(lower, upper)| (Some(lower), Some(upper))), + _ => return, }; - let bounds = - if typevar.is_paramspec(db) && typevar.paramspec_attr(db).is_none() { - let normalize_bound = - |bound: Type<'db>| match bound.resolve_type_alias(db) { - Type::Callable(callable) - if let [signature] = - callable.signatures(db).overloads.as_slice() - && signature.generic_context.is_none() - && let Some(paramspec) = - signature.parameters().as_paramspec() => - { - Some(Type::TypeVar(paramspec)) - } - Type::Callable(callable) => { - Some(Type::Callable(callable.into_paramspec_value(db))) - } - Type::TypeVar(bound) - if bound.is_paramspec(db) - && bound.paramspec_attr(db).is_none() => - { - Some(Type::TypeVar(bound)) - } - _ => None, - }; - normalize_bound(lower).zip(normalize_bound(upper)) - } else { - Some((lower, upper)) - }; let constraints = ConstraintSetBuilder::new(); let result = constraints.into_owned(|constraints| { let Some((lower, upper)) = bounds else { return ConstraintSet::from_bool(constraints, false); }; - ConstraintSet::constrain_typevar( + ConstraintSet::from_constraint( db, env, constraints, - typevar, - lower, - upper, + Constraint::from_evidence(typevar, lower, upper), ) }); let tracked = InternedConstraintSet::new(db, result); diff --git a/crates/ty_python_semantic/src/types/infer/builder.rs b/crates/ty_python_semantic/src/types/infer/builder.rs index 1e8355a9749c50..8959d1e62d8940 100644 --- a/crates/ty_python_semantic/src/types/infer/builder.rs +++ b/crates/ty_python_semantic/src/types/infer/builder.rs @@ -9402,13 +9402,18 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { &bindings, ); - let paramspec_range_subject = match callable_type { - Type::KnownBoundMethod(KnownBoundMethodType::ConstraintSetRange) => { - arguments.find_argument_value("typevar", 1) - } + let paramspec_constraint_subject = match callable_type { + Type::KnownBoundMethod( + KnownBoundMethodType::ConstraintSetLowerBound + | KnownBoundMethodType::ConstraintSetRange, + ) => arguments.find_argument_value("typevar", 1), + Type::KnownBoundMethod( + KnownBoundMethodType::ConstraintSetUpperBound + | KnownBoundMethodType::ConstraintSetEquality, + ) => arguments.find_argument_value("typevar", 0), _ => None, }; - let paramspec_range_subject_is_bare = paramspec_range_subject + let paramspec_constraint_subject_is_bare = paramspec_constraint_subject .filter(|subject| is_dotted_name(subject)) .is_some_and(|subject| { let mut speculative = self.speculate_without_diagnostics(); @@ -9428,11 +9433,12 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { ArgumentsIter::from_ast(arguments), &mut call_arguments, &mut |builder, (_, expr, tcx)| { - // A ParamSpec range can refer to other ParamSpecs. Limit the exception to + // A ParamSpec constraint can refer to other ParamSpecs. Limit the exception to // dotted names so nested types and calls in attribute receivers keep // their ordinary validation. - if (paramspec_range_subject_is_bare - || paramspec_range_subject.is_some_and(|subject| std::ptr::eq(subject, expr))) + if (paramspec_constraint_subject_is_bare + || paramspec_constraint_subject + .is_some_and(|subject| std::ptr::eq(subject, expr))) && is_dotted_name(expr) { let previously_allowed = builder From 23b41e45f464343f686c45df0cc7384e18affe7f Mon Sep 17 00:00:00 2001 From: Dhruv Manilawala Date: Tue, 25 Aug 2026 16:38:27 +0530 Subject: [PATCH 07/15] [ty] Compare missing ParamSpec bounds in the parameter domain --- .../mdtest/type_properties/constraints.md | 39 +++++++++++++++++++ .../src/types/constraints.rs | 25 +++++++++--- .../src/types/signatures.rs | 2 +- 3 files changed, 60 insertions(+), 6 deletions(-) diff --git a/crates/ty_python_semantic/resources/mdtest/type_properties/constraints.md b/crates/ty_python_semantic/resources/mdtest/type_properties/constraints.md index 5c3f6153cb8525..fb54f112ac70ea 100644 --- a/crates/ty_python_semantic/resources/mdtest/type_properties/constraints.md +++ b/crates/ty_python_semantic/resources/mdtest/type_properties/constraints.md @@ -1220,6 +1220,21 @@ def _[T]() -> None: reveal_type(ConstraintSet.range(Sub, T, Super).with_detailed_display()) ``` +Explicit ParamSpec extrema remain visible as supplied bound evidence. + +```py +from typing import Callable, Never +from ty_extensions import Bottom, Top + +def explicit_parameter_extrema[**P]() -> None: + lower = ConstraintSet.range(Bottom[Callable[..., Never]], P, Callable[[int], int]) + # revealed: ConstraintSet[((*args: object, **kwargs: object) ≤ P@explicit_parameter_extrema ≤ (int, /))] + reveal_type(lower.with_detailed_display()) + upper = ConstraintSet.range(Callable[[int], int], P, Top[Callable[..., object]]) + # revealed: ConstraintSet[((int, /) ≤ P@explicit_parameter_extrema ≤ Top[(...)])] + reveal_type(upper.with_detailed_display()) +``` + ParamSpec bounds display the full parameter list without the callable return type. ```py @@ -1623,6 +1638,30 @@ def empty[**P]() -> None: static_assert(ConstraintSet.range(Callable[[], None], P, Callable[[Any], None]) == ConstraintSet.never()) ``` +### Missing bounds + +A missing lower bound is equivalent to the bottom signature, which accepts all arguments. + +```py +from typing import Callable, Never +from ty_extensions import Bottom, Top, static_assert +from ty_extensions._internal import ConstraintSet + +def missing_lower_bound[**P]() -> None: + constraints = ConstraintSet.upper_bound(P, Callable[[int], int]) + expected = ConstraintSet.range(Bottom[Callable[..., Never]], P, Callable[[int], int]) + static_assert(constraints == expected) +``` + +A missing upper bound is equivalent to the top signature, which accepts no calls. + +```py +def missing_upper_bound[**P]() -> None: + constraints = ConstraintSet.lower_bound(Callable[[int], int], P) + expected = ConstraintSet.range(Callable[[int], int], P, Top[Callable[..., object]]) + static_assert(constraints == expected) +``` + Missing endpoints differ from explicit gradual evidence, without requiring solution extraction. ```py diff --git a/crates/ty_python_semantic/src/types/constraints.rs b/crates/ty_python_semantic/src/types/constraints.rs index a7371e5b399bca..b8cb69a8dc2ab2 100644 --- a/crates/ty_python_semantic/src/types/constraints.rs +++ b/crates/ty_python_semantic/src/types/constraints.rs @@ -111,7 +111,7 @@ use crate::types::visitor::{ TypeCollector, TypeKind, TypeVisitor, walk_non_atomic_type, walk_type_with_recursion_guard, }; use crate::types::{ - ApplyTypeMappingVisitor, BoundTypeVarInstance, IntersectionType, Type, TypeContext, + ApplyTypeMappingVisitor, BoundTypeVarInstance, IntersectionType, Parameters, Type, TypeContext, TypeMapping, TypePair, TypeVarBoundOrConstraints, TypeVarVariance, UnionType, }; use crate::{Db, FxIndexMap, FxIndexSet, FxOrderSet, ProgramEnvironment}; @@ -2320,6 +2320,23 @@ fn max_constructor_and_typevar_depth<'db>( } impl<'db> Constraint<'db> { + /// Materialize missing bounds within the subject's domain when comparing constraints. + /// The stored options remain unchanged because absent bounds are not inference evidence. + fn materialized_bounds(self, db: &'db dyn Db) -> (Type<'db>, Type<'db>) { + if self.typevar.is_paramspec(db) && self.typevar.paramspec_attr(db).is_none() { + ( + self.stored_lower_bound() + .map(ConstraintBound::ty) + .unwrap_or_else(|| Type::paramspec_value_callable(db, Parameters::bottom())), + self.stored_upper_bound() + .map(ConstraintBound::ty) + .unwrap_or_else(|| Type::paramspec_value_callable(db, Parameters::top())), + ) + } else { + (self.lower_bound().ty(), self.upper_bound().ty()) + } + } + fn bound_depth(self, db: &'db dyn Db, env: &ProgramEnvironment<'db>) -> (u16, u16) { let both_bounds = self.iter_stored_bounds().map(ConstraintBound::ty); both_bounds.fold((0, 0), |(constructor_depth, typevar_depth), bound| { @@ -2666,10 +2683,8 @@ impl ConstraintId { { return false; } - let other_lower = other_constraint.lower_bound().ty(); - let self_lower = self_constraint.lower_bound().ty(); - let self_upper = self_constraint.upper_bound().ty(); - let other_upper = other_constraint.upper_bound().ty(); + let (self_lower, self_upper) = self_constraint.materialized_bounds(db); + let (other_lower, other_upper) = other_constraint.materialized_bounds(db); other_lower.is_constraint_set_assignable_to(db, env, self_lower) && self_upper.is_constraint_set_assignable_to(db, env, other_upper) } diff --git a/crates/ty_python_semantic/src/types/signatures.rs b/crates/ty_python_semantic/src/types/signatures.rs index b093ca923b3464..2a0729fcfda09c 100644 --- a/crates/ty_python_semantic/src/types/signatures.rs +++ b/crates/ty_python_semantic/src/types/signatures.rs @@ -4825,7 +4825,7 @@ impl<'db> Parameters<'db> { /// Return parameters that represents `(*args: object, **kwargs: object)`, the bottom signature /// (accepts any call, so subtype of all other signatures.) - fn bottom() -> Self { + pub(crate) fn bottom() -> Self { Self::new( [ Parameter::variadic(Name::new_static("args")).with_annotated_type(Type::object()), From 7613592d81759887d95100062852ff888c8afb18 Mon Sep 17 00:00:00 2001 From: Dhruv Manilawala Date: Tue, 25 Aug 2026 19:17:05 +0530 Subject: [PATCH 08/15] [ty] Simplify ParamSpec constraint constructor admission --- .../mdtest/type_properties/constraints.md | 35 +--------- .../src/types/infer/builder.rs | 68 ++++++------------- 2 files changed, 22 insertions(+), 81 deletions(-) diff --git a/crates/ty_python_semantic/resources/mdtest/type_properties/constraints.md b/crates/ty_python_semantic/resources/mdtest/type_properties/constraints.md index fb54f112ac70ea..ddd0d159849d28 100644 --- a/crates/ty_python_semantic/resources/mdtest/type_properties/constraints.md +++ b/crates/ty_python_semantic/resources/mdtest/type_properties/constraints.md @@ -1711,17 +1711,6 @@ def invalid_typevar_bounds[**P, **Q, T]() -> None: static_assert(ConstraintSet.equality(P, Q.args) == ConstraintSet.never()) ``` -Allowing ParamSpec bounds must not admit them as ordinary TypeVar bounds. - -```py -def ordinary_subject[**P, T]() -> None: - ConstraintSet.range(P, T, object) # error: [invalid-type-form] "Bare ParamSpec `P`" - ConstraintSet.range(Never, T, P) # error: [invalid-type-form] "Bare ParamSpec `P`" - ConstraintSet.lower_bound(P, T) # error: [invalid-type-form] "Bare ParamSpec `P`" - ConstraintSet.upper_bound(T, P) # error: [invalid-type-form] "Bare ParamSpec `P`" - ConstraintSet.equality(T, P) # error: [invalid-type-form] "Bare ParamSpec `P`" -``` - Bare TypeVarTuples remain invalid bounds. ```py @@ -1733,37 +1722,15 @@ def typevartuple_bounds[**P, *Us]() -> None: ConstraintSet.equality(P, Us) # error: [invalid-type-form] "TypeVarTuple `Us`" ``` -Nested type expressions and unrelated TypeForm calls keep their ordinary validation. +Allowing ParamSpecs in a constructor does not affect subsequent unrelated TypeForm calls. ```py def accepts_type_form(form: TypeForm[object]) -> None: ... def invalid_forms[**P]() -> None: - ConstraintSet.range(Callable[[int], None], list[P], Callable[[int], None]) # error: [invalid-type-arguments] - ConstraintSet.range(Callable[[P], None], P, Callable[[int], None]) # error: [invalid-type-form] - ConstraintSet.range(Callable[[int], None], P, Callable[..., P]) # error: [invalid-type-form] - ConstraintSet.lower_bound(Callable[[P], None], P) # error: [invalid-type-form] - ConstraintSet.upper_bound(P, Callable[..., P]) # error: [invalid-type-form] - ConstraintSet.equality(list[P], Callable[[int], None]) # error: [invalid-type-arguments] ConstraintSet.equality(P, Callable[[int], None]) accepts_type_form(P) # error: [invalid-type-form] ``` -An attribute receiver must still reject bare ParamSpecs in unrelated TypeForm calls. - -```py -class Holder: - form: TypeForm[int] - -def receiver(form: TypeForm[object]) -> Holder: - return Holder() - -def invalid_receiver[**P, **Q]() -> None: - ConstraintSet.range(int, receiver(P).form, object) # error: [invalid-type-form] - ConstraintSet.lower_bound(Callable[[int], None], receiver(P).form) # error: [invalid-type-form] "Bare ParamSpec `P`" - ConstraintSet.upper_bound(receiver(P).form, Callable[[int], None]) # error: [invalid-type-form] "Bare ParamSpec `P`" - ConstraintSet.equality(P, receiver(Q).form) # error: [invalid-type-form] "Bare ParamSpec `Q`" -``` - ParamSpec components keep their ordinary bounds; bare TypeVarTuples remain invalid subjects. ```py diff --git a/crates/ty_python_semantic/src/types/infer/builder.rs b/crates/ty_python_semantic/src/types/infer/builder.rs index 8959d1e62d8940..0f95337acd4752 100644 --- a/crates/ty_python_semantic/src/types/infer/builder.rs +++ b/crates/ty_python_semantic/src/types/infer/builder.rs @@ -9402,63 +9402,37 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { &bindings, ); - let paramspec_constraint_subject = match callable_type { + // The internal constraint constructors accept ParamSpecs as well as ordinary types. + let previously_allowed_paramspec = matches!( + callable_type, Type::KnownBoundMethod( KnownBoundMethodType::ConstraintSetLowerBound - | KnownBoundMethodType::ConstraintSetRange, - ) => arguments.find_argument_value("typevar", 1), - Type::KnownBoundMethod( - KnownBoundMethodType::ConstraintSetUpperBound - | KnownBoundMethodType::ConstraintSetEquality, - ) => arguments.find_argument_value("typevar", 0), - _ => None, - }; - let paramspec_constraint_subject_is_bare = paramspec_constraint_subject - .filter(|subject| is_dotted_name(subject)) - .is_some_and(|subject| { - let mut speculative = self.speculate_without_diagnostics(); - speculative.expression_cache = None; - match speculative.infer_expression(subject, TypeContext::default()) { - Type::KnownInstance(KnownInstanceType::TypeVar(typevar)) => { - typevar.is_paramspec(db) - } - Type::TypeVar(typevar) => { - typevar.is_paramspec(db) && typevar.paramspec_attr(db).is_none() - } - _ => false, - } - }); + | KnownBoundMethodType::ConstraintSetUpperBound + | KnownBoundMethodType::ConstraintSetEquality + | KnownBoundMethodType::ConstraintSetRange + ) + ) + .then(|| { + self.context + .inference_flags + .replace(InferenceFlags::ALLOW_PARAMSPEC_TYPE_EXPR, true) + }); let bindings_result = self.infer_and_check_argument_types( ArgumentsIter::from_ast(arguments), &mut call_arguments, - &mut |builder, (_, expr, tcx)| { - // A ParamSpec constraint can refer to other ParamSpecs. Limit the exception to - // dotted names so nested types and calls in attribute receivers keep - // their ordinary validation. - if (paramspec_constraint_subject_is_bare - || paramspec_constraint_subject - .is_some_and(|subject| std::ptr::eq(subject, expr))) - && is_dotted_name(expr) - { - let previously_allowed = builder - .context - .inference_flags - .replace(InferenceFlags::ALLOW_PARAMSPEC_TYPE_EXPR, true); - let ty = builder.infer_expression(expr, tcx); - builder.context.inference_flags.set( - InferenceFlags::ALLOW_PARAMSPEC_TYPE_EXPR, - previously_allowed, - ); - ty - } else { - builder.infer_expression(expr, tcx) - } - }, + &mut |builder, (_, expr, tcx)| builder.infer_expression(expr, tcx), &mut bindings, call_expression_tcx, ); + if let Some(previously_allowed_paramspec) = previously_allowed_paramspec { + self.context.inference_flags.set( + InferenceFlags::ALLOW_PARAMSPEC_TYPE_EXPR, + previously_allowed_paramspec, + ); + } + let mut bindings = match bindings_result { Ok(()) => bindings, Err(_) => { From c583e3cf2453c093fb6814a4a41ccc5679897592 Mon Sep 17 00:00:00 2001 From: Dhruv Manilawala Date: Wed, 26 Aug 2026 10:44:22 +0530 Subject: [PATCH 09/15] [ty] Clarify ParamSpec constructor tests --- .../mdtest/type_properties/constraints.md | 25 +++++++++++++------ 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/crates/ty_python_semantic/resources/mdtest/type_properties/constraints.md b/crates/ty_python_semantic/resources/mdtest/type_properties/constraints.md index ddd0d159849d28..e8c88018d4b349 100644 --- a/crates/ty_python_semantic/resources/mdtest/type_properties/constraints.md +++ b/crates/ty_python_semantic/resources/mdtest/type_properties/constraints.md @@ -1220,18 +1220,18 @@ def _[T]() -> None: reveal_type(ConstraintSet.range(Sub, T, Super).with_detailed_display()) ``` -Explicit ParamSpec extrema remain visible as supplied bound evidence. +Explicit bottom and top parameter-list bounds are shown in the constraint. ```py from typing import Callable, Never from ty_extensions import Bottom, Top -def explicit_parameter_extrema[**P]() -> None: +def explicit_bounds[**P]() -> None: lower = ConstraintSet.range(Bottom[Callable[..., Never]], P, Callable[[int], int]) - # revealed: ConstraintSet[((*args: object, **kwargs: object) ≤ P@explicit_parameter_extrema ≤ (int, /))] + # revealed: ConstraintSet[((*args: object, **kwargs: object) ≤ P@explicit_bounds ≤ (int, /))] reveal_type(lower.with_detailed_display()) upper = ConstraintSet.range(Callable[[int], int], P, Top[Callable[..., object]]) - # revealed: ConstraintSet[((int, /) ≤ P@explicit_parameter_extrema ≤ Top[(...)])] + # revealed: ConstraintSet[((int, /) ≤ P@explicit_bounds ≤ Top[(...)])] reveal_type(upper.with_detailed_display()) ``` @@ -1266,7 +1266,7 @@ A ParamSpec constraint describes parameter lists; callable returns are ignored. ### Construction -A bound legacy ParamSpec works with every constructor; equality supplies both endpoints. +Legacy ParamSpecs work with every constructor. A bound of `P ≤ P` adds no restriction. ```py from typing import Callable, ParamSpec @@ -1275,17 +1275,26 @@ from ty_extensions._internal import ConstraintSet P = ParamSpec("P") -def legacy(callback: Callable[P, None]) -> None: +def legacy_range(callback: Callable[P, None]) -> None: constraints = ConstraintSet.range(Callable[[int, str], None], P, Callable[[int, str], None]) reveal_type(constraints) # revealed: ConstraintSet[bool] different_returns = ConstraintSet.range(Callable[[int, str], int], P, Callable[[int, str], str]) static_assert(constraints == different_returns) + +def legacy_lower_bound(callback: Callable[P, None]) -> None: lower = ConstraintSet.lower_bound(Callable[[int, str], int], P) - upper = ConstraintSet.upper_bound(P, Callable[[int, str], str]) - exact = ConstraintSet.equality(P, Callable[[int, str], bytes]) static_assert(lower == ConstraintSet.range(Callable[[int, str], None], P, P)) + +def legacy_upper_bound(callback: Callable[P, None]) -> None: + upper = ConstraintSet.upper_bound(P, Callable[[int, str], str]) static_assert(upper == ConstraintSet.range(P, P, Callable[[int, str], None])) + +def legacy_equality(callback: Callable[P, None]) -> None: + exact = ConstraintSet.equality(P, Callable[[int, str], bytes]) + constraints = ConstraintSet.range(Callable[[int, str], None], P, Callable[[int, str], None]) static_assert(exact == constraints) + lower = ConstraintSet.lower_bound(Callable[[int, str], int], P) + upper = ConstraintSet.upper_bound(P, Callable[[int, str], str]) static_assert(exact == (lower & upper)) static_assert(exact != lower) static_assert(exact != upper) From bc3934cebed94d2f8d2548435c74310ee8cead00 Mon Sep 17 00:00:00 2001 From: Dhruv Manilawala Date: Wed, 26 Aug 2026 11:02:14 +0530 Subject: [PATCH 10/15] [ty] Align ParamSpec constraint test patterns --- .../mdtest/type_properties/constraints.md | 88 +++++++++++-------- 1 file changed, 53 insertions(+), 35 deletions(-) diff --git a/crates/ty_python_semantic/resources/mdtest/type_properties/constraints.md b/crates/ty_python_semantic/resources/mdtest/type_properties/constraints.md index e8c88018d4b349..4e77d81355d809 100644 --- a/crates/ty_python_semantic/resources/mdtest/type_properties/constraints.md +++ b/crates/ty_python_semantic/resources/mdtest/type_properties/constraints.md @@ -1119,6 +1119,21 @@ def same_typevar[T](): static_assert(constraints == expected) ``` +For ParamSpecs too, a self-bound leaves that side of the range unconstrained. + +```pyi +from typing import Callable + +def same_paramspec[**P]() -> None: + constraints = ConstraintSet.range(Callable[[int], None], P, P) + expected = ConstraintSet.lower_bound(Callable[[int], None], P) + static_assert(constraints == expected) + + constraints = ConstraintSet.range(P, P, Callable[[int], None]) + expected = ConstraintSet.upper_bound(P, Callable[[int], None]) + static_assert(constraints == expected) +``` + ## Existential quantification Existential quantification removes the listed typevars from a constraint set. Any constraints that @@ -1197,11 +1212,9 @@ def quantifier_order[S, T]() -> None: ## Displaying constraints -The `with_detailed_display` method can be used to print out the boolean formula that a constraint -set represents. However, this method is only intended for debugging purposes, and we reserve the -right to change the rendering at any time! We therefore do _not_ have a battery of mdtests printing -out all of the different kinds of constraints described above. Here we just test that the method -exists, and provides more detail than otherwise. +`with_detailed_display` prints a constraint set's boolean formula for debugging. + +Exact formatting may change; these tests check signatures, binders, and bound evidence. ```py from ty_extensions import static_assert @@ -1266,12 +1279,12 @@ A ParamSpec constraint describes parameter lists; callable returns are ignored. ### Construction -Legacy ParamSpecs work with every constructor. A bound of `P ≤ P` adds no restriction. +Legacy ParamSpecs work with every constructor. ```py from typing import Callable, ParamSpec from ty_extensions import static_assert -from ty_extensions._internal import ConstraintSet +from ty_extensions._internal import ConstraintSet, is_constraint_set_assignable_to P = ParamSpec("P") @@ -1282,12 +1295,12 @@ def legacy_range(callback: Callable[P, None]) -> None: static_assert(constraints == different_returns) def legacy_lower_bound(callback: Callable[P, None]) -> None: - lower = ConstraintSet.lower_bound(Callable[[int, str], int], P) - static_assert(lower == ConstraintSet.range(Callable[[int, str], None], P, P)) + expected = is_constraint_set_assignable_to(Callable[[int, str], None], Callable[P, None]) + static_assert(ConstraintSet.lower_bound(Callable[[int, str], int], P) == expected) def legacy_upper_bound(callback: Callable[P, None]) -> None: - upper = ConstraintSet.upper_bound(P, Callable[[int, str], str]) - static_assert(upper == ConstraintSet.range(P, P, Callable[[int, str], None])) + expected = is_constraint_set_assignable_to(Callable[P, None], Callable[[int, str], None]) + static_assert(ConstraintSet.upper_bound(P, Callable[[int, str], str]) == expected) def legacy_equality(callback: Callable[P, None]) -> None: exact = ConstraintSet.equality(P, Callable[[int, str], bytes]) @@ -1336,16 +1349,16 @@ P = ParamSpec("P") ```py from typing import Callable from ty_extensions import static_assert -from ty_extensions._internal import ConstraintSet +from ty_extensions._internal import ConstraintSet, is_constraint_set_assignable_to import params def qualified(callback: Callable[params.P, None]) -> None: constraints = ConstraintSet.range(Callable[[int], None], params.P, Callable[[int], None]) reveal_type(constraints) # revealed: ConstraintSet[bool] - lower = ConstraintSet.lower_bound(Callable[[int], None], params.P) - upper = ConstraintSet.upper_bound(params.P, Callable[[int], None]) - static_assert(lower == ConstraintSet.range(Callable[[int], None], params.P, params.P)) - static_assert(upper == ConstraintSet.range(params.P, params.P, Callable[[int], None])) + expected = is_constraint_set_assignable_to(Callable[[int], None], Callable[params.P, None]) + static_assert(ConstraintSet.lower_bound(Callable[[int], None], params.P) == expected) + expected = is_constraint_set_assignable_to(Callable[params.P, None], Callable[[int], None]) + static_assert(ConstraintSet.upper_bound(params.P, Callable[[int], None]) == expected) static_assert(ConstraintSet.equality(params.P, Callable[[int], None]) == constraints) ``` @@ -1439,14 +1452,13 @@ def equality[**P, **Q]() -> None: static_assert(ConstraintSet.equality(Q, P) == constraints) ``` -Each endpoint is retained; a self-bound (`P ≤ P`) leaves that side unconstrained. +Each endpoint is retained when a symbolic lower bound is combined with a concrete upper bound. ```py def symbolic_lower[**P, **Q]() -> None: constraints = ConstraintSet.range(Q, P, Callable[[int], None]) - lower = ConstraintSet.range(Q, P, P) - static_assert(ConstraintSet.lower_bound(Q, P) == lower) - upper = ConstraintSet.range(P, P, Callable[[int], None]) + lower = ConstraintSet.lower_bound(Q, P) + upper = ConstraintSet.upper_bound(P, Callable[[int], None]) static_assert(constraints == (lower & upper)) static_assert(constraints != lower) static_assert(constraints != upper) @@ -1457,9 +1469,8 @@ Symbolic upper bounds likewise retain their concrete lower bound. ```py def symbolic_upper[**P, **Q]() -> None: constraints = ConstraintSet.range(Callable[[int], None], P, Q) - lower = ConstraintSet.range(Callable[[int], None], P, P) - upper = ConstraintSet.range(P, P, Q) - static_assert(ConstraintSet.upper_bound(P, Q) == upper) + lower = ConstraintSet.lower_bound(Callable[[int], None], P) + upper = ConstraintSet.upper_bound(P, Q) static_assert(constraints == (lower & upper)) static_assert(constraints != lower) static_assert(constraints != upper) @@ -1470,8 +1481,8 @@ Three ParamSpecs form a two-sided range, independent of conjunction order. ```py def symbolic_range[**P, **Q, **R]() -> None: constraints = ConstraintSet.range(Q, P, R) - lower = ConstraintSet.range(Q, P, P) - upper = ConstraintSet.range(P, P, R) + lower = ConstraintSet.lower_bound(Q, P) + upper = ConstraintSet.upper_bound(P, R) static_assert(constraints == (lower & upper)) static_assert(constraints == (upper & lower)) static_assert(constraints != lower) @@ -1490,7 +1501,7 @@ from ty_extensions._internal import ConstraintSet, is_constraint_set_assignable_ def unprefixed[**P, **Q]() -> None: constraints = ConstraintSet.range(Callable[Q, int], P, Callable[Q, str]) static_assert(constraints == ConstraintSet.range(Q, P, Q)) - static_assert(ConstraintSet.lower_bound(Callable[Q, bytes], P) == ConstraintSet.range(Q, P, P)) + static_assert(ConstraintSet.lower_bound(Callable[Q, bytes], P) == ConstraintSet.lower_bound(Q, P)) ``` A `Concatenate` bound preserves its prefix and symbolic tail while erasing the return. @@ -1505,7 +1516,8 @@ def prefixed[**P, **Q]() -> None: different_prefix = ConstraintSet.range(Callable[Concatenate[str, Q], None], P, Callable[Concatenate[str, Q], None]) static_assert(constraints != different_prefix) upper = ConstraintSet.upper_bound(P, Callable[Concatenate[int, Q], bytes]) - static_assert(upper == ConstraintSet.range(P, P, Callable[Concatenate[int, Q], None])) + expected_upper = ConstraintSet.upper_bound(P, Callable[Concatenate[int, Q], None]) + static_assert(upper == expected_upper) ``` ### Signature preservation @@ -1671,7 +1683,7 @@ def missing_upper_bound[**P]() -> None: static_assert(constraints == expected) ``` -Missing endpoints differ from explicit gradual evidence, without requiring solution extraction. +Omitted bounds stay absent; explicit `...` bounds remain visible. ```py def missing_bounds[**P]() -> None: @@ -1740,15 +1752,10 @@ def invalid_forms[**P]() -> None: accepts_type_form(P) # error: [invalid-type-form] ``` -ParamSpec components keep their ordinary bounds; bare TypeVarTuples remain invalid subjects. +ParamSpec components keep their ordinary bounds. ```py -Ts = TypeVarTuple("Ts") - -def legacy(value: tuple[*Ts]) -> None: - ConstraintSet.range(Callable[[int], None], Ts, Callable[[int], None]) # error: [invalid-type-form] - -def components_and_typevartuple[**P, *Us]() -> None: +def components[**P]() -> None: args = ConstraintSet.range(tuple[int], P.args, tuple[object, ...]) kwargs = ConstraintSet.range(dict[str, object], P.kwargs, dict[str, object]) reveal_type(args) # revealed: ConstraintSet[bool] @@ -1757,6 +1764,17 @@ def components_and_typevartuple[**P, *Us]() -> None: upper = ConstraintSet.upper_bound(P.args, tuple[object, ...]) static_assert((lower & upper) == args) static_assert(ConstraintSet.equality(P.kwargs, dict[str, object]) == kwargs) +``` + +Bare TypeVarTuples remain invalid subjects. + +```py +Ts = TypeVarTuple("Ts") + +def legacy_typevartuple_subject(value: tuple[*Ts]) -> None: + ConstraintSet.range(Callable[[int], None], Ts, Callable[[int], None]) # error: [invalid-type-form] + +def typevartuple_subject[*Us]() -> None: ConstraintSet.range(Callable[[int], None], Us, Callable[[int], None]) # error: [invalid-type-form] ConstraintSet.lower_bound(Callable[[], None], Us) # error: [invalid-type-form] "TypeVarTuple `Us`" ConstraintSet.upper_bound(Us, Callable[[], None]) # error: [invalid-type-form] "TypeVarTuple `Us`" From e54948c925bb645c4ce3ac2782c2dfc6015d9a72 Mon Sep 17 00:00:00 2001 From: Dhruv Manilawala Date: Wed, 26 Aug 2026 11:28:07 +0530 Subject: [PATCH 11/15] [ty] Trim ParamSpec constructor test mechanics --- .../mdtest/type_properties/constraints.md | 38 +++---------------- 1 file changed, 5 insertions(+), 33 deletions(-) diff --git a/crates/ty_python_semantic/resources/mdtest/type_properties/constraints.md b/crates/ty_python_semantic/resources/mdtest/type_properties/constraints.md index 4e77d81355d809..86f78f3c7c701d 100644 --- a/crates/ty_python_semantic/resources/mdtest/type_properties/constraints.md +++ b/crates/ty_python_semantic/resources/mdtest/type_properties/constraints.md @@ -1212,9 +1212,11 @@ def quantifier_order[S, T]() -> None: ## Displaying constraints -`with_detailed_display` prints a constraint set's boolean formula for debugging. - -Exact formatting may change; these tests check signatures, binders, and bound evidence. +The `with_detailed_display` method can be used to print out the boolean formula that a constraint +set represents. However, this method is only intended for debugging purposes, and we reserve the +right to change the rendering at any time! We therefore do _not_ have a battery of mdtests printing +out all of the different kinds of constraints described above. Here we just test that the method +exists, and provides more detail than otherwise. ```py from ty_extensions import static_assert @@ -1332,36 +1334,6 @@ def aliased_constructor[**P]() -> None: static_assert(constraints == ConstraintSet.range(Callable[[int], None], P, Callable[[int], None])) ``` -### Qualified subjects - -A module-qualified legacy ParamSpec is valid when bound by the enclosing function. - -`params.py`: - -```py -from typing import ParamSpec - -P = ParamSpec("P") -``` - -`main.py`: - -```py -from typing import Callable -from ty_extensions import static_assert -from ty_extensions._internal import ConstraintSet, is_constraint_set_assignable_to -import params - -def qualified(callback: Callable[params.P, None]) -> None: - constraints = ConstraintSet.range(Callable[[int], None], params.P, Callable[[int], None]) - reveal_type(constraints) # revealed: ConstraintSet[bool] - expected = is_constraint_set_assignable_to(Callable[[int], None], Callable[params.P, None]) - static_assert(ConstraintSet.lower_bound(Callable[[int], None], params.P) == expected) - expected = is_constraint_set_assignable_to(Callable[params.P, None], Callable[[int], None]) - static_assert(ConstraintSet.upper_bound(params.P, Callable[[int], None]) == expected) - static_assert(ConstraintSet.equality(params.P, Callable[[int], None]) == constraints) -``` - ### Callable aliases Specialized callable aliases have the same bounds as their expanded parameter lists. From e9520336e29c5ee20a8256810fd5e72ccc5a6c6e Mon Sep 17 00:00:00 2001 From: Dhruv Manilawala Date: Thu, 27 Aug 2026 10:39:17 +0530 Subject: [PATCH 12/15] [ty] Recognize unrestricted ParamSpec constraints --- .../mdtest/type_properties/constraints.md | 15 ++++++++++----- .../src/types/constraints/sequents.rs | 16 +++++++++++++--- 2 files changed, 23 insertions(+), 8 deletions(-) diff --git a/crates/ty_python_semantic/resources/mdtest/type_properties/constraints.md b/crates/ty_python_semantic/resources/mdtest/type_properties/constraints.md index 86f78f3c7c701d..f045e5139c2fd3 100644 --- a/crates/ty_python_semantic/resources/mdtest/type_properties/constraints.md +++ b/crates/ty_python_semantic/resources/mdtest/type_properties/constraints.md @@ -1119,18 +1119,23 @@ def same_typevar[T](): static_assert(constraints == expected) ``` -For ParamSpecs too, a self-bound leaves that side of the range unconstrained. +Constraining a ParamSpec with itself leaves every parameter list possible. ```pyi from typing import Callable +from ty_extensions import Bottom, Top def same_paramspec[**P]() -> None: - constraints = ConstraintSet.range(Callable[[int], None], P, P) - expected = ConstraintSet.lower_bound(Callable[[int], None], P) + constraints = ConstraintSet.upper_bound(P, P) + expected = ConstraintSet.range(Bottom[Callable[..., Never]], P, Top[Callable[..., object]]) + static_assert(constraints == expected) + + constraints = ConstraintSet.lower_bound(P, P) + expected = ConstraintSet.range(Bottom[Callable[..., Never]], P, Top[Callable[..., object]]) static_assert(constraints == expected) - constraints = ConstraintSet.range(P, P, Callable[[int], None]) - expected = ConstraintSet.upper_bound(P, Callable[[int], None]) + constraints = ConstraintSet.equality(P, P) + expected = ConstraintSet.range(Bottom[Callable[..., Never]], P, Top[Callable[..., object]]) static_assert(constraints == expected) ``` diff --git a/crates/ty_python_semantic/src/types/constraints/sequents.rs b/crates/ty_python_semantic/src/types/constraints/sequents.rs index 54a3bf54fc29b7..65dddad2cc4912 100644 --- a/crates/ty_python_semantic/src/types/constraints/sequents.rs +++ b/crates/ty_python_semantic/src/types/constraints/sequents.rs @@ -14,7 +14,7 @@ use crate::types::variance::VarianceInferable; use crate::types::visitor::{ TypeCollector, TypeVisitor, any_over_type, walk_type_with_recursion_guard, }; -use crate::types::{BoundTypeVarInstance, Type, TypeVarVariance}; +use crate::types::{BoundTypeVarInstance, Parameters, Type, TypeVarVariance}; use crate::{Db, ProgramEnvironment}; /// A collection of _sequents_ that describe how the constraints mentioned in a BDD relate to each @@ -233,15 +233,25 @@ impl SequentMap { ) { // If this constraint binds its typevar to `Never ≤ T ≤ object`, then the typevar can take // on any type, and the constraint is always satisfied. + // For a ParamSpec, the bottom and top parameter lists likewise allow every specialization. + // Record this fact without discarding the supplied bounds as inference evidence. let constraint_data = storage.constraint_data(constraint); let lower = constraint_data.lower_bound().ty(); let upper = constraint_data.upper_bound().ty(); - if constraint_data + if (constraint_data .stored_lower_bound() .is_none_or(|bound| bound.ty().is_never()) && constraint_data .stored_upper_bound() - .is_none_or(|bound| bound.ty().is_object()) + .is_none_or(|bound| bound.ty().is_object())) + || (constraint_data.typevar.is_paramspec(db) + && constraint_data.typevar.paramspec_attr(db).is_none() + && { + let (lower, upper) = constraint_data.materialized_bounds(db); + let bottom = Type::paramspec_value_callable(db, Parameters::bottom()); + let top = Type::paramspec_value_callable(db, Parameters::top()); + lower.is_equivalent_to(db, env, bottom) && upper.is_equivalent_to(db, env, top) + }) { self.add_single_tautology(constraint); return; From 319e9dc90304f7ded200f3c5941a9285879c24b2 Mon Sep 17 00:00:00 2001 From: Dhruv Manilawala Date: Thu, 27 Aug 2026 19:30:49 +0530 Subject: [PATCH 13/15] [ty] Simplify ParamSpec constraint tests --- .../mdtest/type_properties/constraints.md | 67 ++++--------------- 1 file changed, 14 insertions(+), 53 deletions(-) diff --git a/crates/ty_python_semantic/resources/mdtest/type_properties/constraints.md b/crates/ty_python_semantic/resources/mdtest/type_properties/constraints.md index f045e5139c2fd3..4fde8fae0b1566 100644 --- a/crates/ty_python_semantic/resources/mdtest/type_properties/constraints.md +++ b/crates/ty_python_semantic/resources/mdtest/type_properties/constraints.md @@ -1224,7 +1224,6 @@ out all of the different kinds of constraints described above. Here we just test exists, and provides more detail than otherwise. ```py -from ty_extensions import static_assert from ty_extensions._internal import ConstraintSet, RegularCallableTypeOf class Super: ... @@ -1265,7 +1264,6 @@ def signature[**P]() -> None: constraints = ConstraintSet.range(RegularCallableTypeOf[complete], P, RegularCallableTypeOf[complete]) # revealed: ConstraintSet[(P@signature = (value: int, /, text: str = "", *args: float, flag: bool = False, **kwargs: bytes))] reveal_type(constraints.with_detailed_display()) - static_assert(ConstraintSet.equality(P, RegularCallableTypeOf[complete]) == constraints) ``` Generic callable bounds keep their own ParamSpec binder. @@ -1276,8 +1274,6 @@ def generic_signature[**P]() -> None: constraints = ConstraintSet.range(RegularCallableTypeOf[callback], P, RegularCallableTypeOf[callback]) # revealed: ConstraintSet[(P@generic_signature = (**Q@callback))] reveal_type(constraints.with_detailed_display()) - # revealed: ConstraintSet[((**Q@callback) ≤ P@generic_signature)] - reveal_type(ConstraintSet.lower_bound(RegularCallableTypeOf[callback], P).with_detailed_display()) ``` ## ParamSpec @@ -1310,14 +1306,8 @@ def legacy_upper_bound(callback: Callable[P, None]) -> None: static_assert(ConstraintSet.upper_bound(P, Callable[[int, str], str]) == expected) def legacy_equality(callback: Callable[P, None]) -> None: - exact = ConstraintSet.equality(P, Callable[[int, str], bytes]) - constraints = ConstraintSet.range(Callable[[int, str], None], P, Callable[[int, str], None]) - static_assert(exact == constraints) - lower = ConstraintSet.lower_bound(Callable[[int, str], int], P) - upper = ConstraintSet.upper_bound(P, Callable[[int, str], str]) - static_assert(exact == (lower & upper)) - static_assert(exact != lower) - static_assert(exact != upper) + equality = ConstraintSet.equality(P, Callable[[int, str], bytes]) + static_assert(equality == ConstraintSet.range(Callable[[int, str], None], P, Callable[[int, str], None])) ``` An empty parameter list is an exact bound, distinct from a one-parameter list. @@ -1327,7 +1317,6 @@ def empty[**P]() -> None: constraints = ConstraintSet.range(Callable[[], None], P, Callable[[], None]) reveal_type(constraints) # revealed: ConstraintSet[bool] static_assert(constraints != ConstraintSet.range(Callable[[int], None], P, Callable[[int], None])) - static_assert(ConstraintSet.equality(P, Callable[[], int]) == constraints) ``` An alias of a known constructor retains its ParamSpec argument rules. @@ -1367,7 +1356,6 @@ def concatenate[**P]() -> None: reveal_type(constraints) # revealed: ConstraintSet[bool] expected = ConstraintSet.range(Callable[[int, str, bool], None], P, Callable[[int, str, bool], None]) static_assert(constraints == expected) - static_assert(ConstraintSet.equality(P, Prefixed[[bool], bytes]) == expected) ``` ### Two-sided bounds @@ -1389,8 +1377,6 @@ class Unrelated: ... def two_sided[**P]() -> None: constraints = ConstraintSet.range(Callable[[Super], None], P, Callable[[Sub], None]) reveal_type(constraints) # revealed: ConstraintSet[bool] - static_assert(constraints != ConstraintSet.range(Callable[[Super], None], P, Callable[[Super], None])) - static_assert(constraints != ConstraintSet.range(Callable[[Sub], None], P, Callable[[Sub], None])) lower = ConstraintSet.lower_bound(Callable[[Super], int], P) upper = ConstraintSet.upper_bound(P, Callable[[Sub], str]) static_assert(constraints == (lower & upper)) @@ -1404,16 +1390,22 @@ Inverted or incomparable bounds are unsatisfiable. def incompatible[**P]() -> None: inverted = ConstraintSet.range(Callable[[Sub], None], P, Callable[[Super], None]) static_assert(inverted == ConstraintSet.never()) + incomparable = ConstraintSet.range(Callable[[Base], None], P, Callable[[Unrelated], None]) + static_assert(incomparable == ConstraintSet.never()) +``` + +Individually satisfiable lower and upper bounds can have an empty intersection. + +```py +def incompatible_intersection[**P]() -> None: lower = ConstraintSet.lower_bound(Callable[[Sub], None], P) upper = ConstraintSet.upper_bound(P, Callable[[Super], None]) static_assert((lower & upper) == ConstraintSet.never()) - incomparable = ConstraintSet.range(Callable[[Base], None], P, Callable[[Unrelated], None]) - static_assert(incomparable == ConstraintSet.never()) ``` ### Symbolic bounds -Another ParamSpec can bind both endpoints, requiring the parameter lists to be equal. +Two ParamSpecs can be constrained to the same parameter list, in either order. ```py from typing import Any, Callable @@ -1421,11 +1413,9 @@ from ty_extensions import static_assert from ty_extensions._internal import ConstraintSet, is_constraint_set_assignable_to def equality[**P, **Q]() -> None: - constraints = ConstraintSet.range(Q, P, Q) + constraints = ConstraintSet.equality(P, Q) expected = is_constraint_set_assignable_to(Callable[P, Any], Callable[Q, Any]) static_assert(constraints == expected) - static_assert(constraints == ConstraintSet.range(P, Q, P)) - static_assert(ConstraintSet.equality(P, Q) == constraints) static_assert(ConstraintSet.equality(Q, P) == constraints) ``` @@ -1453,7 +1443,7 @@ def symbolic_upper[**P, **Q]() -> None: static_assert(constraints != upper) ``` -Three ParamSpecs form a two-sided range, independent of conjunction order. +Three ParamSpecs form a two-sided range. ```py def symbolic_range[**P, **Q, **R]() -> None: @@ -1461,7 +1451,6 @@ def symbolic_range[**P, **Q, **R]() -> None: lower = ConstraintSet.lower_bound(Q, P) upper = ConstraintSet.upper_bound(P, R) static_assert(constraints == (lower & upper)) - static_assert(constraints == (upper & lower)) static_assert(constraints != lower) static_assert(constraints != upper) ``` @@ -1478,7 +1467,6 @@ from ty_extensions._internal import ConstraintSet, is_constraint_set_assignable_ def unprefixed[**P, **Q]() -> None: constraints = ConstraintSet.range(Callable[Q, int], P, Callable[Q, str]) static_assert(constraints == ConstraintSet.range(Q, P, Q)) - static_assert(ConstraintSet.lower_bound(Callable[Q, bytes], P) == ConstraintSet.lower_bound(Q, P)) ``` A `Concatenate` bound preserves its prefix and symbolic tail while erasing the return. @@ -1492,9 +1480,6 @@ def prefixed[**P, **Q]() -> None: static_assert(constraints != ConstraintSet.range(Q, P, Q)) different_prefix = ConstraintSet.range(Callable[Concatenate[str, Q], None], P, Callable[Concatenate[str, Q], None]) static_assert(constraints != different_prefix) - upper = ConstraintSet.upper_bound(P, Callable[Concatenate[int, Q], bytes]) - expected_upper = ConstraintSet.upper_bound(P, Callable[Concatenate[int, Q], None]) - static_assert(upper == expected_upper) ``` ### Signature preservation @@ -1582,7 +1567,6 @@ def overloads[**P]() -> None: static_assert(constraints == ConstraintSet.range(RegularCallableTypeOf[overloaded], P, RegularCallableTypeOf[overloaded])) static_assert(constraints != ConstraintSet.range(Callable[[int], None], P, Callable[[int], None])) static_assert(constraints != ConstraintSet.range(RegularCallableTypeOf[keyword], P, RegularCallableTypeOf[keyword])) - static_assert(ConstraintSet.equality(P, RegularCallableTypeOf[swapped_returns]) == constraints) ``` An overloaded lower bound can satisfy a single signature; an overloaded upper bound requires both. @@ -1617,11 +1601,6 @@ from ty_extensions._internal import ConstraintSet def gradual[**P]() -> None: ellipsis = ConstraintSet.range(Callable[..., int], P, Callable[..., str]) reveal_type(ellipsis.with_detailed_display()) # revealed: ConstraintSet[(P@gradual = (...))] - static_assert(ConstraintSet.equality(P, Callable[..., bytes]) == ellipsis) - # revealed: ConstraintSet[((...) ≤ P@gradual)] - reveal_type(ConstraintSet.lower_bound(Callable[..., int], P).with_detailed_display()) - # revealed: ConstraintSet[(P@gradual ≤ (...))] - reveal_type(ConstraintSet.upper_bound(P, Callable[..., str]).with_detailed_display()) any_parameter = ConstraintSet.range(Callable[[Any], int], P, Callable[[Any], str]) reveal_type(any_parameter.with_detailed_display()) # revealed: ConstraintSet[(P@gradual = (Any, /))] ``` @@ -1676,7 +1655,7 @@ def missing_bounds[**P]() -> None: ### Invalid forms and preservation controls -An ordinary type in either bound makes a ParamSpec range unsatisfiable. +An ordinary type is not a parameter list, so it makes a ParamSpec constraint unsatisfiable. ```py from typing import Callable, Never, TypeVarTuple @@ -1687,8 +1666,6 @@ from ty_extensions._internal import ConstraintSet def invalid_bounds[**P]() -> None: static_assert(ConstraintSet.range(int, P, Callable[[int], None]) == ConstraintSet.never()) static_assert(ConstraintSet.range(Callable[[int], None], P, int) == ConstraintSet.never()) - static_assert(ConstraintSet.range(object, P, object) == ConstraintSet.never()) - static_assert(ConstraintSet.range(Never, P, Never) == ConstraintSet.never()) static_assert(ConstraintSet.lower_bound(int, P) == ConstraintSet.never()) static_assert(ConstraintSet.upper_bound(P, object) == ConstraintSet.never()) static_assert(ConstraintSet.equality(P, Never) == ConstraintSet.never()) @@ -1699,14 +1676,8 @@ An ordinary TypeVar or ParamSpec component is not a complete parameter list. ```py def invalid_typevar_bounds[**P, **Q, T]() -> None: static_assert(ConstraintSet.range(T, P, Callable[[int], None]) == ConstraintSet.never()) - static_assert(ConstraintSet.range(Callable[[int], None], P, T) == ConstraintSet.never()) - static_assert(ConstraintSet.range(Q.args, P, Callable[[int], None]) == ConstraintSet.never()) static_assert(ConstraintSet.range(Callable[[int], None], P, Q.args) == ConstraintSet.never()) static_assert(ConstraintSet.range(Q.kwargs, P, Callable[[int], None]) == ConstraintSet.never()) - static_assert(ConstraintSet.range(Callable[[int], None], P, Q.kwargs) == ConstraintSet.never()) - static_assert(ConstraintSet.lower_bound(T, P) == ConstraintSet.never()) - static_assert(ConstraintSet.upper_bound(P, Q.kwargs) == ConstraintSet.never()) - static_assert(ConstraintSet.equality(P, Q.args) == ConstraintSet.never()) ``` Bare TypeVarTuples remain invalid bounds. @@ -1715,9 +1686,6 @@ Bare TypeVarTuples remain invalid bounds. def typevartuple_bounds[**P, *Us]() -> None: ConstraintSet.range(Us, P, Callable[[int], None]) # error: [invalid-type-form] "TypeVarTuple `Us`" ConstraintSet.range(Callable[[int], None], P, Us) # error: [invalid-type-form] "TypeVarTuple `Us`" - ConstraintSet.lower_bound(Us, P) # error: [invalid-type-form] "TypeVarTuple `Us`" - ConstraintSet.upper_bound(P, Us) # error: [invalid-type-form] "TypeVarTuple `Us`" - ConstraintSet.equality(P, Us) # error: [invalid-type-form] "TypeVarTuple `Us`" ``` Allowing ParamSpecs in a constructor does not affect subsequent unrelated TypeForm calls. @@ -1737,10 +1705,6 @@ def components[**P]() -> None: kwargs = ConstraintSet.range(dict[str, object], P.kwargs, dict[str, object]) reveal_type(args) # revealed: ConstraintSet[bool] reveal_type(kwargs) # revealed: ConstraintSet[bool] - lower = ConstraintSet.lower_bound(tuple[int], P.args) - upper = ConstraintSet.upper_bound(P.args, tuple[object, ...]) - static_assert((lower & upper) == args) - static_assert(ConstraintSet.equality(P.kwargs, dict[str, object]) == kwargs) ``` Bare TypeVarTuples remain invalid subjects. @@ -1753,7 +1717,4 @@ def legacy_typevartuple_subject(value: tuple[*Ts]) -> None: def typevartuple_subject[*Us]() -> None: ConstraintSet.range(Callable[[int], None], Us, Callable[[int], None]) # error: [invalid-type-form] - ConstraintSet.lower_bound(Callable[[], None], Us) # error: [invalid-type-form] "TypeVarTuple `Us`" - ConstraintSet.upper_bound(Us, Callable[[], None]) # error: [invalid-type-form] "TypeVarTuple `Us`" - ConstraintSet.equality(Us, Callable[[], None]) # error: [invalid-type-form] "TypeVarTuple `Us`" ``` From 15e37c36cc3eb968bb44bd47c0954b7099285527 Mon Sep 17 00:00:00 2001 From: Dhruv Manilawala Date: Thu, 27 Aug 2026 19:30:49 +0530 Subject: [PATCH 14/15] [ty] Separate constraint constructor handlers --- .../ty_python_semantic/src/types/call/bind.rs | 194 +++++++++++------- 1 file changed, 125 insertions(+), 69 deletions(-) diff --git a/crates/ty_python_semantic/src/types/call/bind.rs b/crates/ty_python_semantic/src/types/call/bind.rs index ac97064abdaf61..d0cb4caffc00a1 100644 --- a/crates/ty_python_semantic/src/types/call/bind.rs +++ b/crates/ty_python_semantic/src/types/call/bind.rs @@ -34,8 +34,8 @@ use crate::types::ProgramEnvironment; use crate::types::call::arguments::{CallArgumentTypes, Expansion, is_expandable_type}; use crate::types::callable::CallableTypeKind; use crate::types::constraints::{ - Constraint, ConstraintSet, ConstraintSetBuilder, PathBound, PathBoundSolution, PathBounds, - SolutionPaths, Solutions, + ConstraintSet, ConstraintSetBuilder, PathBound, PathBoundSolution, PathBounds, SolutionPaths, + Solutions, }; use crate::types::context::LintDiagnosticGuardBuilder; use crate::types::dedicated::pydantic::{self, ConfigBoolean}; @@ -233,6 +233,35 @@ fn inferable_typevars_from_tuple<'db>( typevars.map(|typevars| TypeVarSet::from_typevars(db, typevars)) } +/// Converts a bound from an internal `ConstraintSet` constructor to its solver representation. +/// A bare `ParamSpec` requires parameter lists; ordinary typevars and `ParamSpec` components keep +/// their type bounds. `None` indicates an invalid supplied bound, not an omitted endpoint. +fn normalize_constraint_bound<'db>( + db: &'db dyn Db, + env: &ProgramEnvironment<'db>, + typevar: BoundTypeVarInstance<'db>, + bound: Type<'db>, +) -> Option> { + let bound = bound.project_type_form(db, env); + if !typevar.is_paramspec(db) || typevar.paramspec_attr(db).is_some() { + return Some(bound); + } + match bound.resolve_type_alias(db) { + Type::Callable(callable) + if let [signature] = callable.signatures(db).overloads.as_slice() + && signature.generic_context.is_none() + && let Some(paramspec) = signature.parameters().as_paramspec() => + { + Some(Type::TypeVar(paramspec)) + } + Type::Callable(callable) => Some(Type::Callable(callable.into_paramspec_value(db))), + Type::TypeVar(bound) if bound.is_paramspec(db) && bound.paramspec_attr(db).is_none() => { + Some(Type::TypeVar(bound)) + } + _ => None, + } +} + /// Priority levels for call errors in intersection types. /// Higher values indicate more specific errors that should take precedence. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] @@ -2815,88 +2844,115 @@ impl<'db> Bindings<'db> { } }, - Type::KnownBoundMethod( - method @ (KnownBoundMethodType::ConstraintSetLowerBound - | KnownBoundMethodType::ConstraintSetUpperBound - | KnownBoundMethodType::ConstraintSetEquality - | KnownBoundMethodType::ConstraintSetRange), - ) => { - let typevar = match (method, overload.parameter_types()) { - ( - KnownBoundMethodType::ConstraintSetLowerBound, - [Some(_), Some(typevar)], + Type::KnownBoundMethod(KnownBoundMethodType::ConstraintSetLowerBound) => { + let [Some(lower), Some(typevar)] = overload.parameter_types() else { + return; + }; + let typevar = typevar.project_type_form(db, env); + let Type::TypeVar(typevar) = typevar else { + return; + }; + let constraints = ConstraintSetBuilder::new(); + let result = constraints.into_owned(|constraints| { + let Some(lower) = normalize_constraint_bound(db, env, typevar, *lower) + else { + return ConstraintSet::from_bool(constraints, false); + }; + ConstraintSet::constrain_typevar_lower_bound( + db, + env, + constraints, + typevar, + lower, ) - | ( - KnownBoundMethodType::ConstraintSetRange, - [Some(_), Some(typevar), Some(_)], + }); + let tracked = InternedConstraintSet::new(db, result); + overload.set_return_type(Type::KnownInstance( + KnownInstanceType::ConstraintSet(tracked), + )); + } + + Type::KnownBoundMethod(KnownBoundMethodType::ConstraintSetUpperBound) => { + let [Some(typevar), Some(upper)] = overload.parameter_types() else { + return; + }; + let typevar = typevar.project_type_form(db, env); + let Type::TypeVar(typevar) = typevar else { + return; + }; + let constraints = ConstraintSetBuilder::new(); + let result = constraints.into_owned(|constraints| { + let Some(upper) = normalize_constraint_bound(db, env, typevar, *upper) + else { + return ConstraintSet::from_bool(constraints, false); + }; + ConstraintSet::constrain_typevar_upper_bound( + db, + env, + constraints, + typevar, + upper, ) - | ( - KnownBoundMethodType::ConstraintSetUpperBound - | KnownBoundMethodType::ConstraintSetEquality, - [Some(typevar), Some(_)], - ) => typevar.project_type_form(db, env), - _ => return, + }); + let tracked = InternedConstraintSet::new(db, result); + overload.set_return_type(Type::KnownInstance( + KnownInstanceType::ConstraintSet(tracked), + )); + } + + Type::KnownBoundMethod(KnownBoundMethodType::ConstraintSetEquality) => { + let [Some(typevar), Some(value)] = overload.parameter_types() else { + return; }; + let typevar = typevar.project_type_form(db, env); let Type::TypeVar(typevar) = typevar else { return; }; - let normalize_bound = |bound: Type<'db>| { - let bound = bound.project_type_form(db, env); - if !typevar.is_paramspec(db) || typevar.paramspec_attr(db).is_some() { - return Some(bound); - } - match bound.resolve_type_alias(db) { - Type::Callable(callable) - if let [signature] = - callable.signatures(db).overloads.as_slice() - && signature.generic_context.is_none() - && let Some(paramspec) = - signature.parameters().as_paramspec() => - { - Some(Type::TypeVar(paramspec)) - } - Type::Callable(callable) => { - Some(Type::Callable(callable.into_paramspec_value(db))) - } - Type::TypeVar(bound) - if bound.is_paramspec(db) - && bound.paramspec_attr(db).is_none() => - { - Some(Type::TypeVar(bound)) - } - _ => None, - } + let constraints = ConstraintSetBuilder::new(); + let result = constraints.into_owned(|constraints| { + let Some(value) = normalize_constraint_bound(db, env, typevar, *value) + else { + return ConstraintSet::from_bool(constraints, false); + }; + ConstraintSet::constrain_typevar( + db, + env, + constraints, + typevar, + value, + value, + ) + }); + let tracked = InternedConstraintSet::new(db, result); + overload.set_return_type(Type::KnownInstance( + KnownInstanceType::ConstraintSet(tracked), + )); + } + + Type::KnownBoundMethod(KnownBoundMethodType::ConstraintSetRange) => { + let [Some(lower), Some(typevar), Some(upper)] = overload.parameter_types() + else { + return; }; - // A failed normalization rejects a supplied bound; it is not a missing - // endpoint. Preserve absent sides separately inside each successful pair. - let bounds = match (method, overload.parameter_types()) { - (KnownBoundMethodType::ConstraintSetLowerBound, [Some(lower), _]) => { - normalize_bound(*lower).map(|lower| (Some(lower), None)) - } - (KnownBoundMethodType::ConstraintSetUpperBound, [_, Some(upper)]) => { - normalize_bound(*upper).map(|upper| (None, Some(upper))) - } - (KnownBoundMethodType::ConstraintSetEquality, [_, Some(value)]) => { - normalize_bound(*value).map(|value| (Some(value), Some(value))) - } - ( - KnownBoundMethodType::ConstraintSetRange, - [Some(lower), _, Some(upper)], - ) => normalize_bound(*lower) - .zip(normalize_bound(*upper)) - .map(|(lower, upper)| (Some(lower), Some(upper))), - _ => return, + let typevar = typevar.project_type_form(db, env); + let Type::TypeVar(typevar) = typevar else { + return; }; let constraints = ConstraintSetBuilder::new(); let result = constraints.into_owned(|constraints| { - let Some((lower, upper)) = bounds else { + let (Some(lower), Some(upper)) = ( + normalize_constraint_bound(db, env, typevar, *lower), + normalize_constraint_bound(db, env, typevar, *upper), + ) else { return ConstraintSet::from_bool(constraints, false); }; - ConstraintSet::from_constraint( + ConstraintSet::constrain_typevar( db, env, constraints, - Constraint::from_evidence(typevar, lower, upper), + typevar, + lower, + upper, ) }); let tracked = InternedConstraintSet::new(db, result); From a1a8ab97785c4a2a8eb1935c72c6b19c0c26d5b4 Mon Sep 17 00:00:00 2001 From: Dhruv Manilawala Date: Thu, 27 Aug 2026 21:04:19 +0530 Subject: [PATCH 15/15] [ty] Use ParamSpec-aware effective constraint bounds Supply parameter-list bottom/top for missing ParamSpec endpoints through Constraint's effective accessors. Keep absent stored bounds separate from inference evidence and preserve ordinary TypeVar behavior. --- .../src/types/constraints.rs | 70 ++++++++++--------- .../src/types/constraints/sequents.rs | 38 +++++----- 2 files changed, 54 insertions(+), 54 deletions(-) diff --git a/crates/ty_python_semantic/src/types/constraints.rs b/crates/ty_python_semantic/src/types/constraints.rs index b8cb69a8dc2ab2..1d54304b9731a8 100644 --- a/crates/ty_python_semantic/src/types/constraints.rs +++ b/crates/ty_python_semantic/src/types/constraints.rs @@ -1851,20 +1851,36 @@ impl<'db> Constraint<'db> { /// Returns the effective lower endpoint with its provenance. /// - /// An absent endpoint defaults to `Validity(Never)`. Explicit `Evidence(Never)` is returned - /// unchanged. - fn lower_bound(self) -> ConstraintBound<'db> { + /// An absent endpoint defaults to `Validity(Never)`, or a validity bound for the bottom + /// parameter list of a bare `ParamSpec`. Explicit `Evidence(Never)` is returned unchanged. + fn lower_bound(self, db: &'db dyn Db) -> ConstraintBound<'db> { self.stored_lower_bound() - .unwrap_or_else(ConstraintBound::missing_lower) + .unwrap_or_else(|| ConstraintBound::Validity(self.default_lower_bound(db))) } /// Returns the effective upper endpoint with its provenance. /// - /// An absent endpoint defaults to `Validity(object)`. Explicit `Evidence(object)` is returned - /// unchanged. - fn upper_bound(self) -> ConstraintBound<'db> { + /// An absent endpoint defaults to `Validity(object)`, or a validity bound for the top + /// parameter list of a bare `ParamSpec`. Explicit `Evidence(object)` is returned unchanged. + fn upper_bound(self, db: &'db dyn Db) -> ConstraintBound<'db> { self.stored_upper_bound() - .unwrap_or_else(ConstraintBound::missing_upper) + .unwrap_or_else(|| ConstraintBound::Validity(self.default_upper_bound(db))) + } + + fn default_lower_bound(self, db: &'db dyn Db) -> Type<'db> { + if self.typevar.is_paramspec(db) && self.typevar.paramspec_attr(db).is_none() { + Type::paramspec_value_callable(db, Parameters::bottom()) + } else { + Type::Never + } + } + + fn default_upper_bound(self, db: &'db dyn Db) -> Type<'db> { + if self.typevar.is_paramspec(db) && self.typevar.paramspec_attr(db).is_none() { + Type::paramspec_value_callable(db, Parameters::top()) + } else { + Type::object() + } } /// Returns the stored lower endpoint with its provenance, or `None` if absent. @@ -1909,8 +1925,9 @@ impl<'db> Constraint<'db> { /// A bound derived only from validity remains validity. Any derivation that also depends on /// evidence is itself evidence. /// -/// Every type is a supertype of `Never` and a subtype of `object`, so `Validity(Never)` represents -/// an absent lower bound and `Validity(object)` represents an absent upper bound. +/// Missing endpoints are stored as `None`. [`Constraint`] supplies validity defaults appropriate +/// for its typevar: `Never`/`object` for ordinary types, and bottom/top parameter lists for bare +/// `ParamSpec`s. #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, get_size2::GetSize, salsa::SalsaValue)] enum ConstraintBound<'db> { Validity(Type<'db>), @@ -2008,7 +2025,7 @@ impl<'db> ConstraintBound<'db> { /// /// Missing bounds are stored as `None`, making equality and hashing cheaper for this common case. /// Ordinary validity identities (`Never`/`object`) are canonicalized to absence. The owning -/// [`Constraint`] supplies effective defaults for missing endpoints. +/// [`Constraint`] supplies effective defaults appropriate for its typevar. #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, get_size2::GetSize, salsa::SalsaValue)] struct ConstraintBounds<'db> { lower: Option>, @@ -2320,23 +2337,6 @@ fn max_constructor_and_typevar_depth<'db>( } impl<'db> Constraint<'db> { - /// Materialize missing bounds within the subject's domain when comparing constraints. - /// The stored options remain unchanged because absent bounds are not inference evidence. - fn materialized_bounds(self, db: &'db dyn Db) -> (Type<'db>, Type<'db>) { - if self.typevar.is_paramspec(db) && self.typevar.paramspec_attr(db).is_none() { - ( - self.stored_lower_bound() - .map(ConstraintBound::ty) - .unwrap_or_else(|| Type::paramspec_value_callable(db, Parameters::bottom())), - self.stored_upper_bound() - .map(ConstraintBound::ty) - .unwrap_or_else(|| Type::paramspec_value_callable(db, Parameters::top())), - ) - } else { - (self.lower_bound().ty(), self.upper_bound().ty()) - } - } - fn bound_depth(self, db: &'db dyn Db, env: &ProgramEnvironment<'db>) -> (u16, u16) { let both_bounds = self.iter_stored_bounds().map(ConstraintBound::ty); both_bounds.fold((0, 0), |(constructor_depth, typevar_depth), bound| { @@ -2501,8 +2501,8 @@ impl<'db> Constraint<'db> { // `upper`. We use an existential check here ("is there *some* assignment where // `lower ≤ upper`?") rather than a universal check, because the bounds may mention // typevars — e.g., `Sequence[int] ≤ A ≤ Sequence[T]` is satisfiable when `int ≤ T`. - let effective_lower = constraint.lower_bound().ty(); - let effective_upper = constraint.upper_bound().ty(); + let effective_lower = constraint.lower_bound(db).ty(); + let effective_upper = constraint.upper_bound(db).ty(); if lower.is_some() && upper.is_some() { let when = effective_lower.when_constraint_set_assignable_to_owned(db, env, effective_upper); @@ -2683,8 +2683,10 @@ impl ConstraintId { { return false; } - let (self_lower, self_upper) = self_constraint.materialized_bounds(db); - let (other_lower, other_upper) = other_constraint.materialized_bounds(db); + let self_lower = self_constraint.lower_bound(db).ty(); + let self_upper = self_constraint.upper_bound(db).ty(); + let other_lower = other_constraint.lower_bound(db).ty(); + let other_upper = other_constraint.upper_bound(db).ty(); other_lower.is_constraint_set_assignable_to(db, env, self_lower) && self_upper.is_constraint_set_assignable_to(db, env, other_upper) } @@ -5088,8 +5090,8 @@ impl ConstraintAssignment { std::fmt::from_fn(move |f| { let constraint_data = storage.constraint_data(self.constraint()); - // Render supplied bounds, using the ordinary identities below only for the - // shorthand for omitted endpoints. + // Render supplied bounds, not the synthetic parameter-list defaults. The ordinary + // identities below retain the existing shorthand for omitted endpoints. let lower = constraint_data .stored_lower_bound() .map_or(Type::Never, ConstraintBound::ty); diff --git a/crates/ty_python_semantic/src/types/constraints/sequents.rs b/crates/ty_python_semantic/src/types/constraints/sequents.rs index 65dddad2cc4912..70a9cc228c0698 100644 --- a/crates/ty_python_semantic/src/types/constraints/sequents.rs +++ b/crates/ty_python_semantic/src/types/constraints/sequents.rs @@ -14,7 +14,7 @@ use crate::types::variance::VarianceInferable; use crate::types::visitor::{ TypeCollector, TypeVisitor, any_over_type, walk_type_with_recursion_guard, }; -use crate::types::{BoundTypeVarInstance, Parameters, Type, TypeVarVariance}; +use crate::types::{BoundTypeVarInstance, Type, TypeVarVariance}; use crate::{Db, ProgramEnvironment}; /// A collection of _sequents_ that describe how the constraints mentioned in a BDD relate to each @@ -194,8 +194,8 @@ impl SequentMap { ) { // If the post constraint is unsatisfiable, then the antecedents contradict each other. let post_data = storage.constraint_data(post); - let post_lower = post_data.lower_bound().ty(); - let post_upper = post_data.upper_bound().ty(); + let post_lower = post_data.lower_bound(db).ty(); + let post_upper = post_data.upper_bound(db).ty(); let (when, source_order) = storage.load( db, env, @@ -235,9 +235,10 @@ impl SequentMap { // on any type, and the constraint is always satisfied. // For a ParamSpec, the bottom and top parameter lists likewise allow every specialization. // Record this fact without discarding the supplied bounds as inference evidence. + // Some internal producers still use the ordinary identities for ParamSpecs. let constraint_data = storage.constraint_data(constraint); - let lower = constraint_data.lower_bound().ty(); - let upper = constraint_data.upper_bound().ty(); + let lower = constraint_data.lower_bound(db).ty(); + let upper = constraint_data.upper_bound(db).ty(); if (constraint_data .stored_lower_bound() .is_none_or(|bound| bound.ty().is_never()) @@ -246,12 +247,8 @@ impl SequentMap { .is_none_or(|bound| bound.ty().is_object())) || (constraint_data.typevar.is_paramspec(db) && constraint_data.typevar.paramspec_attr(db).is_none() - && { - let (lower, upper) = constraint_data.materialized_bounds(db); - let bottom = Type::paramspec_value_callable(db, Parameters::bottom()); - let top = Type::paramspec_value_callable(db, Parameters::top()); - lower.is_equivalent_to(db, env, bottom) && upper.is_equivalent_to(db, env, top) - }) + && lower.is_equivalent_to(db, env, constraint_data.default_lower_bound(db)) + && upper.is_equivalent_to(db, env, constraint_data.default_upper_bound(db))) { self.add_single_tautology(constraint); return; @@ -292,7 +289,8 @@ impl SequentMap { // implication. (That is, this check directly encodes `(L ≤ T ≤ U) → (L ≤ U)` as an // implication.) - // Missing endpoints add no relation to derive. Keep defaults out of stored evidence. + // Missing endpoints add no relation to derive. In particular, do not turn a synthetic + // ParamSpec default into stored evidence through a lazy comparison with another typevar. if constraint_data.stored_lower_bound().is_none() || constraint_data.stored_upper_bound().is_none() || lower.is_never() @@ -488,8 +486,8 @@ impl SequentMap { let bound_upper_bound = bound_constraint_data.stored_upper_bound(); // A pivot can equal a missing endpoint's identity, such as an alias of Never. That // comparison is useful even though there is no stored bound to copy. - let effective_bound_lower = bound_constraint_data.lower_bound(); - let effective_bound_upper = bound_constraint_data.upper_bound(); + let effective_bound_lower = bound_constraint_data.lower_bound(db); + let effective_bound_upper = bound_constraint_data.upper_bound(db); // Transitive pivots require subtyping; classes with dynamic bases can be assignable to // unrelated types without being subtypes. @@ -718,8 +716,8 @@ impl SequentMap { let constrained_data = storage.constraint_data(constrained_constraint); let constrained_typevar = constrained_data.typevar; let constrained_identity = constrained_typevar.identity(db); - let constrained_lower_bound = constrained_data.lower_bound(); - let constrained_upper_bound = constrained_data.upper_bound(); + let constrained_lower_bound = constrained_data.lower_bound(db); + let constrained_upper_bound = constrained_data.upper_bound(db); let constrained_lower = constrained_lower_bound.ty(); let constrained_upper = constrained_upper_bound.ty(); @@ -938,13 +936,13 @@ impl SequentMap { |bound_constraint: ConstraintId, constrained_constraint: ConstraintId| { let bound_data = storage.constraint_data(bound_constraint); let bound_typevar = bound_data.typevar; - let bound_lower_bound = bound_data.lower_bound(); - let bound_upper_bound = bound_data.upper_bound(); + let bound_lower_bound = bound_data.lower_bound(db); + let bound_upper_bound = bound_data.upper_bound(db); let bound_lower = bound_lower_bound.ty(); let constrained_data = storage.constraint_data(constrained_constraint); let constrained_typevar = constrained_data.typevar; - let constrained_lower_bound = constrained_data.lower_bound(); - let constrained_upper_bound = constrained_data.upper_bound(); + let constrained_lower_bound = constrained_data.lower_bound(db); + let constrained_upper_bound = constrained_data.upper_bound(db); let constrained_lower = constrained_lower_bound.ty(); let constrained_upper = constrained_upper_bound.ty();