Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
49 commits
Select commit Hold shift + click to select a range
544dafa
add more sequents
dcreager Nov 26, 2025
998b20f
add for_each_path
dcreager Nov 26, 2025
3b509e9
it's a start
dcreager Nov 20, 2025
20ecb56
add ConstraintSetAssignability relation
dcreager Nov 26, 2025
fc2f175
use constraint set assignable
dcreager Nov 26, 2025
b7fb679
it works!
dcreager Nov 26, 2025
9950c12
these need to be positional only to be assignable
dcreager Nov 26, 2025
fedc754
this gets recursively expanded now
dcreager Nov 26, 2025
2c62674
clean up the diff
dcreager Nov 26, 2025
2b949b3
Merge remote-tracking branch 'origin/main' into dcreager/callable-return
dcreager Dec 2, 2025
d88120b
mark these as TODO
dcreager Dec 2, 2025
957304e
mdlint
dcreager Dec 2, 2025
7bbf839
hackity hack
dcreager Dec 2, 2025
3045258
clippity bippity
dcreager Dec 2, 2025
a303b7a
Merge remote-tracking branch 'origin/main' into dcreager/callable-return
dcreager Dec 2, 2025
58c67fd
don't create T ≤ T constraints
dcreager Nov 25, 2025
beb2956
carry over failing test from conformance suite
dcreager Dec 3, 2025
a0f64bd
even more hack
dcreager Dec 3, 2025
d3fd988
fix tests
dcreager Dec 3, 2025
2e46c8d
Merge remote-tracking branch 'origin/main' into dcreager/callable-return
dcreager Dec 3, 2025
db5834d
add failing tests
dcreager Dec 3, 2025
77ce24a
allow multiple overloads/callables when inferring
dcreager Dec 3, 2025
85e6143
use self annotation in synthesized __init__ callable
dcreager Dec 3, 2025
3bcca62
doc
dcreager Dec 3, 2025
75e9d66
self
dcreager Dec 3, 2025
94aca37
skip non-inferable
dcreager Dec 3, 2025
b90cdfc
generic
dcreager Dec 3, 2025
1e33d25
fix test
dcreager Dec 3, 2025
b314119
catch self-referential typevars
dcreager Dec 4, 2025
54a4f2e
use ConstraintSetAssignability for constraint bounds
dcreager Dec 4, 2025
3384392
treat each overload separately
dcreager Dec 4, 2025
8c7e20a
format, really?!?!
dcreager Dec 4, 2025
c0dc6cf
Merge remote-tracking branch 'origin/main' into dcreager/callable-return
dcreager Dec 5, 2025
c74eb12
pull this out into a helper method
dcreager Dec 5, 2025
db488e3
Merge remote-tracking branch 'origin/main' into dcreager/callable-return
dcreager Dec 5, 2025
056258c
cs assignability for paramspecs
dcreager Dec 5, 2025
657685f
don't throw away return type
dcreager Dec 5, 2025
b84a35f
oh hey that's a real bug
dcreager Dec 5, 2025
a372e63
different TODO explanation for overload example
dcreager Dec 5, 2025
d47e9a6
callable invariance rears its head again
dcreager Dec 5, 2025
6138152
Revert "skip non-inferable"
dcreager Dec 5, 2025
c60560f
do this at the overloads level
dcreager Dec 5, 2025
ecb9c13
gotta get those return types too
dcreager Dec 7, 2025
22c7fc4
don't pivot on never or object
dcreager Dec 7, 2025
c56d5cc
not failing anymore
dcreager Dec 7, 2025
b3e4855
any here
dcreager Dec 7, 2025
81fc51e
update test TODOs
dcreager Dec 7, 2025
72e0c32
clippy
dcreager Dec 7, 2025
f29200c
Merge remote-tracking branch 'origin/main' into dcreager/callable-return
dcreager Dec 7, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,7 @@ reveal_type(B().name_does_not_matter()) # revealed: B
reveal_type(B().positional_only(1)) # revealed: B
reveal_type(B().keyword_only(x=1)) # revealed: B
# TODO: This should deally be `B`
reveal_type(B().decorated_method()) # revealed: Unknown
reveal_type(B().decorated_method()) # revealed: Self@decorated_method | Unknown

reveal_type(B().a_property) # revealed: B

Expand Down
4 changes: 1 addition & 3 deletions crates/ty_python_semantic/resources/mdtest/async.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,9 +43,7 @@ async def main():
loop = asyncio.get_event_loop()
with concurrent.futures.ThreadPoolExecutor() as pool:
result = await loop.run_in_executor(pool, blocking_function)

# TODO: should be `int`
reveal_type(result) # revealed: Unknown
reveal_type(result) # revealed: int
```

### `asyncio.Task`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,8 +82,7 @@ def get_default() -> str:

reveal_type(field(default=1)) # revealed: dataclasses.Field[Literal[1]]
reveal_type(field(default=None)) # revealed: dataclasses.Field[None]
# TODO: this could ideally be `dataclasses.Field[str]` with a better generics solver
reveal_type(field(default_factory=get_default)) # revealed: dataclasses.Field[Unknown]
reveal_type(field(default_factory=get_default)) # revealed: dataclasses.Field[str]
```

## dataclass_transform field_specifiers
Expand Down
11 changes: 6 additions & 5 deletions crates/ty_python_semantic/resources/mdtest/decorators.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,11 +144,12 @@ from functools import cache
def f(x: int) -> int:
return x**2

# TODO: Should be `_lru_cache_wrapper[int]`
reveal_type(f) # revealed: _lru_cache_wrapper[Unknown]

# TODO: Should be `int`
reveal_type(f(1)) # revealed: Unknown
# TODO: revealed: _lru_cache_wrapper[int]
# revealed: _lru_cache_wrapper[int] | _lru_cache_wrapper[Unknown]
reveal_type(f)
# TODO: revealed: int
# revealed: int | Unknown
reveal_type(f(1))
```

## Lambdas as decorators
Expand Down
4 changes: 2 additions & 2 deletions crates/ty_python_semantic/resources/mdtest/deprecated.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,9 @@ classes. Uses of these items should subsequently produce a warning.
from typing_extensions import deprecated

@deprecated("use OtherClass")
def myfunc(): ...
def myfunc(x: int): ...

myfunc() # error: [deprecated] "use OtherClass"
myfunc(1) # error: [deprecated] "use OtherClass"
```

```py
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -379,14 +379,13 @@ T = TypeVar("T")
def invoke(fn: Callable[[A], B], value: A) -> B:
return fn(value)

def identity(x: T) -> T:
def identity(x: T, /) -> T:
Copy link
Member Author

Choose a reason for hiding this comment

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

This is an interesting nuance: Callable[[A], B] creates a signature containing positional-only parameters, so we have to make the signature here positional-only as well to make identity pass the assignability check for it to be a valid argument to the fn parameter.

(That said, I'm not convinced that's...correct? Are we mixing up the ordering of the assignability check operands somewhere?)

Copy link
Member

Choose a reason for hiding this comment

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

Yeah, this should work, it does when there aren't any type variables involved (https://play.ty.dev/1663d70f-4daa-492f-84d7-c35c9009fbba):

from typing import Callable

def f(c: Callable[[int], int]): ...

def c(a: int) -> int: return 1

f(c)

Copy link
Member

@dhruvmanila dhruvmanila Nov 27, 2025

Choose a reason for hiding this comment

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

Ok, I looked into this a bit more and what I'm seeing is that:

In infer_map_impl, the formal signature is Callable[[A], B] and the actual signature is the identity function and you invoke formal_signature.when_constraint_set_assignable_to(..., actual_signature, ...) which means we're checking when is Callable[[A], B] assignable to identity function which leads to checking when is the positional-only parameter (A) is assignable to positional-or-keyword parameter (a: int) which is never. This is because you cannot substitute a callable that takes a positional-or-keyword parameter with a callable that takes a positional-only parameter (https://play.ty.dev/7b31e7a0-19c8-4d2b-8bf6-507764372c0d).

This can be tested using Protocol to define a callable that takes a positional-or-keyword parameter:

from typing import Protocol

class PositionalOrKeyword(Protocol):
    def __call__(self, a: int) -> None: ...

def positional_only(a: int, /) -> None: ...
def positional_or_keyword(a: int) -> None: ...

def test(c: PositionalOrKeyword) -> None:
    c(a=1)

Calling test using positional_only would fail at runtime.

return x

def head(xs: list[T]) -> T:
def head(xs: list[T], /) -> T:
return xs[0]

# TODO: this should be `Literal[1]`
reveal_type(invoke(identity, 1)) # revealed: Unknown
reveal_type(invoke(identity, 1)) # revealed: Literal[1]

# TODO: this should be `Unknown | int`
reveal_type(invoke(head, [1, 2, 3])) # revealed: Unknown
Comment on lines 390 to 391
Copy link
Member Author

Choose a reason for hiding this comment

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

This TODO is not also removed because we end up inferring this constraint set when comparing head to Callable[[A], B]:

(B@invoke ≤ T@head) ∧ (list[T@head] ≤ A@invoke)

We then try to remove T@head from the constraint set by calculating

∃T@head ⋅ (B@invoke ≤ T@head) ∧ (list[T@head] ≤ A@invoke)

We should be able to pick T@head = B@invoke and simplify that to

(B@invoke = *) ∧ (list[B@invoke] ≤ A@invoke)

which I think would then be enough to propagate through the return type to discharge this TODO. I think this would require adding more derived facts to the sequent map.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -490,8 +490,7 @@ V = TypeVar("V", default="V")
class D(Generic[V]):
x: V

# TODO: we shouldn't leak a typevar like this in type inference
reveal_type(D().x) # revealed: V@D
reveal_type(D().x) # revealed: Unknown
```

## Regression
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -334,14 +334,13 @@ from typing import Callable
def invoke[A, B](fn: Callable[[A], B], value: A) -> B:
return fn(value)

def identity[T](x: T) -> T:
def identity[T](x: T, /) -> T:
return x

def head[T](xs: list[T]) -> T:
def head[T](xs: list[T], /) -> T:
return xs[0]

# TODO: this should be `Literal[1]`
reveal_type(invoke(identity, 1)) # revealed: Unknown
reveal_type(invoke(identity, 1)) # revealed: Literal[1]

# TODO: this should be `Unknown | int`
reveal_type(invoke(head, [1, 2, 3])) # revealed: Unknown
Expand Down Expand Up @@ -583,3 +582,108 @@ def f[T](x: T, y: Not[T]) -> T:
y = x # error: [invalid-assignment]
return x
```

## `Callable` parameters

We can recurse into the parameters and return values of `Callable` parameters to infer
specializations of a generic function.

```py
from typing import Any, Callable, NoReturn, overload, Self

def accepts_callable[**P, R](callable: Callable[P, R]) -> Callable[P, R]:
return callable

def returns_int() -> int:
raise NotImplementedError

# revealed: int
reveal_type(accepts_callable(returns_int)())

class ClassWithoutConstructor: ...

# revealed: ClassWithoutConstructor
reveal_type(accepts_callable(ClassWithoutConstructor)())

class ClassWithNew:
def __new__(cls, *args, **kwargs) -> Self:
raise NotImplementedError

# revealed: ClassWithNew
reveal_type(accepts_callable(ClassWithNew)())

class ClassWithInit:
def __init__(self) -> None: ...

# revealed: ClassWithInit
reveal_type(accepts_callable(ClassWithInit)())

class ClassWithNewAndInit:
def __new__(cls, *args, **kwargs) -> Self:
raise NotImplementedError

def __init__(self) -> None: ...

# revealed: ClassWithNewAndInit
reveal_type(accepts_callable(ClassWithNewAndInit)())

class Meta(type):
def __call__(cls, *args: Any, **kwargs: Any) -> NoReturn:
raise NotImplementedError

class ClassWithNoReturnMetatype(metaclass=Meta):
def __new__(cls, *args: Any, **kwargs: Any) -> Self:
raise NotImplementedError

# revealed: Never
reveal_type(accepts_callable(ClassWithNoReturnMetatype)())

class Proxy: ...

class ClassWithIgnoredInit:
def __new__(cls) -> Proxy:
return Proxy()

def __init__(self, x: int) -> None: ...

# revealed: Proxy
reveal_type(accepts_callable(ClassWithIgnoredInit)())

class ClassWithOverloadedInit[T]:
t: T # invariant

@overload
def __init__(self: "ClassWithOverloadedInit[int]", x: int) -> None: ...
@overload
def __init__(self: "ClassWithOverloadedInit[str]", x: str) -> None: ...
def __init__(self, x: int | str) -> None: ...

# TODO: The old solver cannot handle this overloaded constructor. The ideal solution is that we
# would solve **P once, and map it to the entire overloaded signature of the constructor. This
# mapping would have to include the return types, since there are different return types for each
# overload. We would then also have to determine that R must be equal to the return type of **P's
# solution.

# TODO: revealed: ClassWithOverloadedInit[int]
# revealed: ClassWithOverloadedInit[int] | ClassWithOverloadedInit[str]
reveal_type(accepts_callable(ClassWithOverloadedInit)(0))
# TODO: revealed: ClassWithOverloadedInit[str]
# revealed: ClassWithOverloadedInit[int] | ClassWithOverloadedInit[str]
reveal_type(accepts_callable(ClassWithOverloadedInit)(""))

class GenericClass[T]:
t: T # invariant

def __new__(cls, x: list[T], y: list[T]) -> Self:
raise NotImplementedError

def _(x: list[str]):
# TODO: This fails because we are not propagating GenericClass's generic context into the
# Callable that we create for it.
# TODO: revealed: GenericClass[str]
# TODO: no errors
# revealed: GenericClass[T@GenericClass]
# error: [invalid-argument-type]
# error: [invalid-argument-type]
reveal_type(accepts_callable(GenericClass)(x, x))
```
Original file line number Diff line number Diff line change
Expand Up @@ -392,15 +392,13 @@ def f3(x: int, y: str) -> int:
return 1

# TODO: This should reveal `(x: int, y: str) -> bool` but there's a cycle: https://github.com/astral-sh/ty/issues/1729
reveal_type(f3) # revealed: ((x: int, y: str) -> bool) | ((x: Divergent, y: Divergent) -> bool)
reveal_type(f3) # revealed: ((x: int, y: str) -> bool) | ((...) -> bool)

reveal_type(f3(1, "a")) # revealed: bool
reveal_type(f3(x=1, y="a")) # revealed: bool
reveal_type(f3(1, y="a")) # revealed: bool
reveal_type(f3(y="a", x=1)) # revealed: bool

# TODO: There should only be one error but the type of `f3` is a union: https://github.com/astral-sh/ty/issues/1729
# error: [missing-argument] "No argument provided for required parameter `y`"
# error: [missing-argument] "No argument provided for required parameter `y`"
f3(1)
# error: [invalid-argument-type] "Argument is incorrect: Expected `int`, found `Literal["a"]`"
Expand Down Expand Up @@ -480,7 +478,8 @@ class C[**P]:
def __init__(self, f: Callable[P, int]) -> None:
self.f = f

def f(x: int, y: str) -> bool:
# Note that the return type must match exactly, since C is invariant on the return type of C.f.
def f(x: int, y: str) -> int:
return True

c = C(f)
Expand Down Expand Up @@ -632,18 +631,24 @@ def with_parameters[**P](f: Callable[P, int], *args: P.args, **kwargs: P.kwargs)
return str(f(*args, **kwargs))
return nested

reveal_type(change_return_type(int_int)) # revealed: Overload[(x: int) -> str, (x: str) -> str]
# revealed: Overload[(x: int) -> str, (x: str) -> str]
reveal_type(change_return_type(int_int))

# TODO: This shouldn't error and should pick the first overload because of the return type
# error: [invalid-argument-type]
reveal_type(change_return_type(int_str)) # revealed: Overload[(x: int) -> str, (x: str) -> str]
# revealed: Overload[(x: int) -> str, (x: str) -> str]
reveal_type(change_return_type(int_str))

# TODO: revealed: Overload[(x: int) -> str, (x: str) -> str]
# error: [invalid-argument-type]
reveal_type(change_return_type(str_str)) # revealed: Overload[(x: int) -> str, (x: str) -> str]
# revealed: (...) -> str
reveal_type(change_return_type(str_str))

# TODO: Both of these shouldn't raise an error
# error: [invalid-argument-type]
reveal_type(with_parameters(int_int, 1)) # revealed: Overload[(x: int) -> str, (x: str) -> str]
# revealed: Overload[(x: int) -> str, (x: str) -> str]
reveal_type(with_parameters(int_int, 1))
# error: [invalid-argument-type]
reveal_type(with_parameters(int_int, "a")) # revealed: Overload[(x: int) -> str, (x: str) -> str]
# revealed: Overload[(x: int) -> str, (x: str) -> str]
reveal_type(with_parameters(int_int, "a"))
```
Original file line number Diff line number Diff line change
Expand Up @@ -863,7 +863,7 @@ reveal_type(C[int]().y) # revealed: int
class D[T = T]:
x: T

reveal_type(D().x) # revealed: T@D
reveal_type(D().x) # revealed: Unknown
```

[pep 695]: https://peps.python.org/pep-0695/
Original file line number Diff line number Diff line change
Expand Up @@ -304,7 +304,7 @@ x11: list[Literal[1] | Literal[2] | Literal[3]] = [1, 2, 3]
reveal_type(x11) # revealed: list[Literal[1, 2, 3]]

x12: Y[Y[Literal[1]]] = [[1]]
reveal_type(x12) # revealed: list[Y[Literal[1]]]
reveal_type(x12) # revealed: list[list[Literal[1]]]
Copy link
Member Author

Choose a reason for hiding this comment

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

This is because we're now using specialize_recursive instead of specialize_partial.


x13: list[tuple[Literal[1], Literal[2], Literal[3]]] = [(1, 2, 3)]
reveal_type(x13) # revealed: list[tuple[Literal[1], Literal[2], Literal[3]]]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,9 @@ mdtest path: crates/ty_python_semantic/resources/mdtest/deprecated.md
1 | from typing_extensions import deprecated
2 |
3 | @deprecated("use OtherClass")
4 | def myfunc(): ...
4 | def myfunc(x: int): ...
5 |
6 | myfunc() # error: [deprecated] "use OtherClass"
6 | myfunc(1) # error: [deprecated] "use OtherClass"
7 | from typing_extensions import deprecated
8 |
9 | @deprecated("use BetterClass")
Expand All @@ -42,9 +42,9 @@ mdtest path: crates/ty_python_semantic/resources/mdtest/deprecated.md
warning[deprecated]: The function `myfunc` is deprecated
--> src/mdtest_snippet.py:6:1
|
4 | def myfunc(): ...
4 | def myfunc(x: int): ...
5 |
6 | myfunc() # error: [deprecated] "use OtherClass"
6 | myfunc(1) # error: [deprecated] "use OtherClass"
| ^^^^^^ use OtherClass
7 | from typing_extensions import deprecated
|
Expand Down
2 changes: 1 addition & 1 deletion crates/ty_python_semantic/resources/mdtest/with/async.md
Original file line number Diff line number Diff line change
Expand Up @@ -213,7 +213,7 @@ async def connect() -> AsyncGenerator[Session]:
yield Session()

# TODO: this should be `() -> _AsyncGeneratorContextManager[Session, None]`
reveal_type(connect) # revealed: () -> _AsyncGeneratorContextManager[Unknown, None]
reveal_type(connect) # revealed: (...) -> _AsyncGeneratorContextManager[Unknown, None]

async def main():
async with connect() as session:
Expand Down
Loading
Loading