Skip to content

[ty] preserve bounds in generic callable assignability#24134

Open
Hugo-Polloli wants to merge 2 commits intoastral-sh:mainfrom
Hugo-Polloli:bounded-callable-assignability
Open

[ty] preserve bounds in generic callable assignability#24134
Hugo-Polloli wants to merge 2 commits intoastral-sh:mainfrom
Hugo-Polloli:bounded-callable-assignability

Conversation

@Hugo-Polloli
Copy link
Copy Markdown
Contributor

@Hugo-Polloli Hugo-Polloli commented Mar 23, 2026

Summary

Fixes astral-sh/ty#3114

Relates to astral-sh/ty#2859

Fix generic callable assignability for bounded and constrained source typevars.

ty already enforced one coherent specialization for unconstrained source typevars when comparing a generic callable value against a fully static callable target. Bounded and constrained source generics still fell back to the legacy relation path, so cases like TypeOf[bounded] being accepted as Callable[[str], int] were still wrong.

This PR applies the same rule to bounded and constrained source generics in the existing fully-static-target case:

  • source is a generic callable value
  • target is a fully static callable signature
  • relation is plain assignability
  • target contributes no inferable signature typevars

In constraints.rs, existential reduction now preserves bounds and constraints before removing the inferable source type variables, so bounded and constrained typevars are not treated as unconstrained during reduction. The method regression also covers a set of inferable type variables that is larger than the method's own generic context because an outer class typevar participates in the signature.

Test Plan

  • TypeOf[identity] and RegularCallableTypeOf[identity] reject Callable[[str], int] while still accepting Callable[[str], str]
  • bounded generic callable values reject incompatible concrete targets without losing the link between repeated occurrences of the same type variable
  • constrained generic callable values accept only concrete targets that match one allowed specialization
  • generic methods whose inferable type variables include an outer class typevar preserve that outer typevar through the special assignability path

@astral-sh-bot astral-sh-bot Bot added the ty Multi-file analysis & type inference label Mar 23, 2026
@astral-sh-bot
Copy link
Copy Markdown

astral-sh-bot Bot commented Mar 23, 2026

Typing conformance results

No changes detected ✅

Current numbers
The percentage of diagnostics emitted that were expected errors held steady at 86.46%. The percentage of expected errors that received a diagnostic held steady at 80.68%. The number of fully passing files held steady at 67/132.

@astral-sh-bot
Copy link
Copy Markdown

astral-sh-bot Bot commented Mar 23, 2026

Memory usage report

Summary

Project Old New Diff Outcome
prefect 711.11MB 711.11MB +0.00% (1.76kB)
flake8 47.87MB 47.87MB -
trio 117.31MB 117.31MB -
sphinx 264.18MB 264.15MB -0.01% (24.25kB) ⬇️

Significant changes

Click to expand detailed breakdown

prefect

Name Old New Diff Outcome
UnionType<'db>::from_two_elements_::interned_arguments 2.44MB 2.44MB +0.02% (528.00B)
IntersectionType<'db>::from_two_elements_::interned_arguments 39.27kB 39.62kB +0.88% (352.00B)
IntersectionType<'db>::from_two_elements_ 43.02kB 43.35kB +0.78% (344.00B)
UnionType<'db>::from_two_elements_ 5.22MB 5.22MB +0.01% (336.00B)
infer_expression_types_impl 61.36MB 61.36MB +0.00% (240.00B)

sphinx

Name Old New Diff Outcome
check_file_impl 4.92MB 4.90MB -0.30% (15.14kB) ⬇️
infer_expression_types_impl 21.35MB 21.35MB -0.03% (5.82kB) ⬇️
infer_definition_types 23.59MB 23.59MB -0.00% (800.00B) ⬇️
Type<'db>::apply_specialization_ 1.64MB 1.64MB -0.03% (492.00B) ⬇️
Type<'db>::apply_specialization_::interned_arguments 1.44MB 1.44MB -0.03% (480.00B) ⬇️
CallableType 1.09MB 1.09MB -0.04% (432.00B) ⬇️
FunctionType 3.10MB 3.10MB -0.01% (368.00B) ⬇️
UnionType<'db>::from_two_elements_::interned_arguments 779.80kB 780.05kB +0.03% (264.00B) ⬇️
Specialization 1.01MB 1.01MB -0.02% (256.00B) ⬇️
InferableTypeVarsInner 85.23kB 84.98kB -0.29% (252.00B) ⬇️
cached_protocol_interface 187.44kB 187.22kB -0.12% (228.00B) ⬇️
FunctionType<'db>::signature_ 2.26MB 2.26MB -0.01% (200.00B) ⬇️
IntersectionType<'db>::from_two_elements_::interned_arguments 31.20kB 31.37kB +0.55% (176.00B) ⬇️
IntersectionType<'db>::from_two_elements_ 35.03kB 35.20kB +0.48% (172.00B) ⬇️
UnionType<'db>::from_two_elements_ 1.38MB 1.38MB +0.01% (168.00B) ⬇️
... 7 more

@astral-sh-bot
Copy link
Copy Markdown

astral-sh-bot Bot commented Mar 23, 2026

ecosystem-analyzer results

Lint rule Added Removed Changed
invalid-argument-type 1 45 0
invalid-await 40 0 0
invalid-assignment 0 1 0
invalid-return-type 1 0 0
no-matching-overload 0 1 0
Total 42 47 0

Changes in flaky projects detected. Raw diff output excludes flaky projects; see the HTML report for details.

Raw diff (48 changes)
Expression (https://github.com/cognitedata/Expression)
- expression/collections/array.py:496:1 error[invalid-argument-type] Argument is incorrect: Expected `(Never, /, *args: Unknown, **kwargs: Unknown) -> Never`, found `def filter[_TSource](source: TypedArray[_TSource], predicate: (_TSource, /) -> bool) -> TypedArray[_TSource]`
- expression/collections/array.py:592:1 error[invalid-argument-type] Argument is incorrect: Expected `[_TSource](Never, /, count: int) -> Never`, found `def take[_TSource](source: TypedArray[_TSource], count: int) -> TypedArray[_TSource]`
- expression/collections/array.py:606:1 error[invalid-argument-type] Argument is incorrect: Expected `[_TSource](Never, /, count: int) -> Never`, found `def take_last[_TSource](source: TypedArray[_TSource], count: int) -> TypedArray[_TSource]`
- expression/collections/map.py:258:1 error[invalid-argument-type] Argument is incorrect: Expected `(Never, /, *args: Unknown, **kwargs: Unknown) -> Never`, found `def add[_Key, _Value](table: Map[_Key, _Value], key: _Key, value: _Value) -> Map[_Key, _Value]`
- expression/collections/map.py:279:1 error[invalid-argument-type] Argument is incorrect: Expected `(Never, /, *args: Unknown, **kwargs: Unknown) -> Never`, found `def change[_Key, _Value](table: Map[_Key, _Value], key: _Key, fn: (Option[_Value], /) -> Option[_Value]) -> Map[_Key, _Value]`
- expression/collections/map.py:391:1 error[invalid-argument-type] Argument is incorrect: Expected `(Never, /, *args: Unknown, **kwargs: Unknown) -> Never`, found `def filter[_Key, _Value](table: Map[_Key, _Value], predicate: (_Key, _Value, /) -> bool) -> Map[_Key, _Value]`
- expression/collections/map.py:431:1 error[invalid-argument-type] Argument is incorrect: Expected `(Never, /, *args: Unknown, **kwargs: Unknown) -> Never`, found `def remove[_Key, _Value](table: Map[_Key, _Value], key: _Key) -> Map[_Key, _Value]`
- expression/collections/seq.py:93:38 error[invalid-argument-type] Argument is incorrect: Expected `Never`, found `Self@filter`
- expression/collections/seq.py:287:31 error[invalid-argument-type] Argument to function `pipe` is incorrect: Expected `(Self@skip, /) -> Unknown`, found `(Never, /) -> Never`
- expression/collections/seq.py:316:31 error[invalid-argument-type] Argument to function `pipe` is incorrect: Expected `(Self@take, /) -> Unknown`, found `(Never, /) -> Never`
- expression/collections/seq.py:515:1 error[invalid-argument-type] Argument is incorrect: Expected `(Never, /, *args: Unknown, **kwargs: Unknown) -> Never`, found `def filter[_TSource](source: Iterable[_TSource], predicate: (_TSource, /) -> bool) -> Iterable[_TSource]`
- expression/collections/seq.py:846:1 error[invalid-argument-type] Argument is incorrect: Expected `[_TSource](Never, /, count: int) -> Never`, found `def skip[_TSource](source: Iterable[_TSource], count: int) -> Iterable[_TSource]`
- expression/collections/seq.py:892:1 error[invalid-argument-type] Argument is incorrect: Expected `[_TSource](Never, /, count: int) -> Never`, found `def take[_TSource](source: Iterable[_TSource], count: int) -> Iterable[_TSource]`
- expression/collections/block.py:560:1 error[invalid-argument-type] Argument is incorrect: Expected `(Never, /, *args: Unknown, **kwargs: Unknown) -> Never`, found `def append[_TSource](source: Block[_TSource], other: Block[_TSource]) -> Block[_TSource]`
- expression/collections/block.py:607:1 error[invalid-argument-type] Argument is incorrect: Expected `(Never, /, *args: Unknown, **kwargs: Unknown) -> Never`, found `def filter[_TSource](source: Block[_TSource], predicate: (_TSource, /) -> bool) -> Block[_TSource]`
- expression/collections/block.py:873:1 error[invalid-argument-type] Argument is incorrect: Expected `[_TSource](Never, /, count: int) -> Never`, found `def skip[_TSource](source: Block[_TSource], count: int) -> Block[_TSource]`
- expression/collections/block.py:887:1 error[invalid-argument-type] Argument is incorrect: Expected `[_TSource](Never, /, count: int) -> Never`, found `def skip_last[_TSource](source: Block[_TSource], count: int) -> Block[_TSource]`
- expression/collections/block.py:901:1 error[invalid-argument-type] Argument is incorrect: Expected `[_TSourceSortable](Never, /, reverse: bool = False) -> Never`, found `def sort[_TSourceSortable](source: Block[_TSourceSortable], reverse: bool = False) -> Block[_TSourceSortable]`
- expression/collections/block.py:918:1 error[invalid-argument-type] Argument is incorrect: Expected `(Never, /, *args: Unknown, **kwargs: Unknown) -> Never`, found `def sort_with[_TSource](source: Block[_TSource], func: (_TSource, /) -> Any, reverse: bool = False) -> Block[_TSource]`
- expression/collections/block.py:947:1 error[invalid-argument-type] Argument is incorrect: Expected `[_TSource](Never, /, count: int) -> Never`, found `def take[_TSource](source: Block[_TSource], count: int) -> Block[_TSource]`
- expression/collections/block.py:961:1 error[invalid-argument-type] Argument is incorrect: Expected `[_TSource](Never, /, count: int) -> Never`, found `def take_last[_TSource](source: Block[_TSource], count: int) -> Block[_TSource]`
- expression/core/result.py:432:1 error[invalid-argument-type] Argument is incorrect: Expected `(Never, /, *args: Unknown, **kwargs: Unknown) -> Never`, found `def filter[_TSource, _TError](result: Result[_TSource, _TError], predicate: (_TSource, /) -> bool, default: _TError) -> Result[_TSource, _TError]`
- expression/core/result.py:441:1 error[invalid-argument-type] Argument is incorrect: Expected `(Never, /, *args: Unknown, **kwargs: Unknown) -> Never`, found `def filter_with[_TSource, _TError](result: Result[_TSource, _TError], predicate: (_TSource, /) -> bool, default: (_TSource, /) -> _TError) -> Result[_TSource, _TError]`
- expression/core/result.py:455:1 error[invalid-argument-type] Argument is incorrect: Expected `(Never, /, *args: Unknown, **kwargs: Unknown) -> Never`, found `def or_else[_TSource, _TError](result: Result[_TSource, _TError], other: Result[_TSource, _TError]) -> Result[_TSource, _TError]`
- expression/core/result.py:460:1 error[invalid-argument-type] Argument is incorrect: Expected `(Never, /, *args: Unknown, **kwargs: Unknown) -> Never`, found `def or_else_with[_TSource, _TError](result: Result[_TSource, _TError], other: (_TError, /) -> Result[_TSource, _TError]) -> Result[_TSource, _TError]`
- expression/extra/parser.py:54:39 error[invalid-argument-type] Argument is incorrect: Expected `Never`, found `Parser[_B@ignore_then]`
- expression/extra/parser.py:60:24 error[invalid-argument-type] Argument is incorrect: Expected `Never`, found `Self@or_else`
- expression/extra/parser.py:145:1 error[invalid-argument-type] Argument is incorrect: Expected `(Never, /, *args: Unknown, **kwargs: Unknown) -> Never`, found `def or_else[_A](p1: Parser[_A], p2: Parser[_A]) -> Parser[_A]`
- expression/extra/parser.py:381:1 error[invalid-argument-type] Argument is incorrect: Expected `[_B](Never, /, p1: Parser[Any]) -> Never`, found `def ignore_then[_B](p2: Parser[_B], p1: Parser[Any]) -> Parser[_B]`
- expression/extra/parser.py:457:33 error[invalid-argument-type] Argument is incorrect: Expected `Never`, found `Parser[Block[str]]`
- expression/extra/parser.py:490:21 error[invalid-argument-type] Argument is incorrect: Expected `Never`, found `Parser[_A@between]`
- tests/test_array.py:333:23 error[invalid-argument-type] Argument to function `pipe` is incorrect: Expected `(TypedArray[int], /) -> Unknown`, found `(Never, /) -> Never`
- tests/test_block.py:300:23 error[invalid-argument-type] Argument to function `pipe` is incorrect: Expected `(Block[int], /) -> Unknown`, found `(Never, /) -> Never`
- tests/test_block.py:309:23 error[invalid-argument-type] Argument to function `pipe` is incorrect: Expected `(Block[int], /) -> Unknown`, found `(Never, /) -> Never`
- tests/test_block.py:321:9 error[invalid-argument-type] Argument to function `pipe` is incorrect: Expected `(Block[str], /) -> Unknown`, found `(Never, /) -> Never`
- tests/test_compose.py:21:16 error[invalid-assignment] Object of type `(Never, /) -> Never` is not assignable to `(int, /) -> int`
- tests/test_map.py:74:21 error[invalid-argument-type] Argument to function `pipe` is incorrect: Expected `(Map[str, int] | Unknown, /) -> Unknown`, found `(Never, /) -> Never`
- tests/test_result.py:296:24 error[invalid-argument-type] Argument to bound method `pipe` is incorrect: Expected `(Result[Literal[42], Any], /) -> Unknown`, found `(Never, /) -> Never`
- tests/test_result.py:320:24 error[invalid-argument-type] Argument to bound method `pipe` is incorrect: Expected `(Result[Literal[42], Any], /) -> Unknown`, found `(Never, /) -> Never`
- tests/test_result.py:513:24 error[invalid-argument-type] Argument to bound method `pipe` is incorrect: Expected `(Result[Literal[42], Any], /) -> Unknown`, found `(Never, /) -> Never`
- tests/test_result.py:540:24 error[invalid-argument-type] Argument to bound method `pipe` is incorrect: Expected `(Result[Literal[42], Any], /) -> Unknown`, found `(Never, /) -> Never`
- tests/test_seq.py:229:23 error[invalid-argument-type] Argument to function `pipe` is incorrect: Expected `(Seq[int], /) -> Unknown`, found `(Never, /) -> Never`
- tests/test_seq.py:239:23 error[invalid-argument-type] Argument to function `pipe` is incorrect: Expected `(Seq[int], /) -> Unknown`, found `(Never, /) -> Never`
- tests/test_seq.py:248:19 error[invalid-argument-type] Argument to function `pipe` is incorrect: Expected `(Iterable[int], /) -> Unknown`, found `(Never, /) -> Never`

materialize (https://github.com/MaterializeInc/materialize)
- misc/python/materialize/cli/mz_workload_anonymize.py:251:13 error[no-matching-overload] No overload of bound method `join` matches arguments
+ misc/python/materialize/cli/mz_workload_anonymize.py:251:26 error[invalid-argument-type] Argument to function `__new__` is incorrect: Expected `(Sized, /) -> Sized`, found `def escape[AnyStr](pattern: AnyStr) -> AnyStr`

porcupine (https://github.com/Akuli/porcupine)
- porcupine/pluginmanager.py:133:49 error[invalid-argument-type] Argument to function `__new__` is incorrect: Expected `Iterable[Never]`, found `Unknown | str`

sympy (https://github.com/sympy/sympy)
- sympy/parsing/mathematica.py:692:38 error[invalid-argument-type] Argument to function `__new__` is incorrect: Expected `Iterable[Never]`, found `Unknown | list[str]`

Full report with detailed diff (timing results)

@Hugo-Polloli
Copy link
Copy Markdown
Contributor Author

Hugo-Polloli commented Mar 23, 2026

ecosystem-analyzer results

materialize is the only project I would classify as regressing here, but the root bug is already on main: sorted(..., key=len) widens the result type instead of preserving the iterable element type. That seems to match astral-sh/ty#2056.

What this PR changes is that the widened type now surfaces as a downstream error in map(re.escape, ...). So the added diagnostic is real, but it depends on a preexisting inference bug on main.

I would prefer not to chase that interaction down in this PR, and instead acknowledge it here and let it be resolved by fixing astral-sh/ty#2056.

The rest of the non-flaky ecosystem changes look like improvements to me! 🚀

@Hugo-Polloli Hugo-Polloli marked this pull request as ready for review March 23, 2026 20:16
@astral-sh-bot astral-sh-bot Bot requested a review from oconnor663 March 23, 2026 20:16
Extend generic callable assignability to bounded and constrained source typevars while preserving bounds and constraints during existential reduction. Keep the special callable path scoped to first-class callable-value comparisons so structural protocol checks stay on the general callable relation path.
Copy link
Copy Markdown
Member

@dcreager dcreager left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Having looked at astral-sh/ty#3114 more closely, I think that issue is a dup of astral-sh/ty#2799. So I think a better way to fix this will be to piggy-back on that work, where I am migrating SpecializationBuilder to use a constraint set for its internal state, rather than a simple hash map of type assignments.

I think your new mdtests are very useful for persisting expectations, though. Could you update this to keep just the new mdtests (with TODO comments calling out the expected behavior), but removing the implementation? I think it introduces a lot of incidental complexity that shouldn't be needed, and the SpecializationBuilder migration is close enough to landing that I don't think it's worth adding this even as an intermediate step.

Comment on lines +1333 to +1336
T_bound = TypeVar("T_bound", bound=object)

def bounded(t: T_bound) -> T_bound:
return t
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: The other examples in this file use PEP 695 syntax. Can you update these to match? Or is there something that only works with legacy syntax? The classes below might require a particular variance, but you can control that by adding extra definitions to the class body, like we do here:

class Bivariant[T]:
def __init__(self, value: T): ...
class Covariant[T]:
def __init__(self, value: T): ...
def pop(self) -> T:
raise NotImplementedError
class Contravariant[T]:
def __init__(self, value: T): ...
def push(self, value: T) -> None: ...
class Invariant[T]:
x: T
def __init__(self, value: T): ...

Comment on lines +56 to +59
class Child(Base, Generic[PT]):
parent_id: PT

def get_parent_id(self) -> PT: ...
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same. This file uses PEP 695 syntax below. Please make sure the file remains consistent with itself if possible

@carljm carljm removed their request for review April 9, 2026 01:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ty Multi-file analysis & type inference

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Generic callable assignability accepts incompatible bounded and constrained source typevars

3 participants