[ty] Return type inference (#29253)
## Summary
<img width="331" height="155" alt="image"
src="https://github.com/user-attachments/assets/8e1b5c40-57b7-44ec-a197-3770e9fc3236"
align=right />
This PR adds return type inference as a new feature to ty. The feature
is currently *disabled by default*. Users can activate the feature by
setting `analysis.return-type-inference` to `"always"`.
In a standalone function, the inferred return type is simply the union
of the types of all reachable `return`ed expressions, as shown in the
image on the right. If control flow can reach the end of the function
scope, we add `None` to the union. In a (non-final) method on a
(non-final) class, we additionally union with `Unknown` to allow child
classes to widen the intended return type. For example, imagine
```py
class TaskBase:
def compute(self):
return None
class IntTask(TaskBase):
def compute(self):
return 42
```
Here, we infer a return type of `None | Unknown` for `TaskBase.compute`.
Otherwise, if we inferred `None`, we would prohibit child classes from
returning something else, which seems undesirable. Another interesting
scenario is when the base class method is annotated, but the child class
method is not:
```py
class Base:
def method(self) -> int:
return 0
class Child(Base):
def method(self):
return 1
```
Here, we respect the base class definition and infer `Child.method` as
returning `int`, not `int | Unknown`.
This work is based on https://github.com/astral-sh/ruff/pull/17371 by
@mtshiba (thank you!)
There are a few notable things that are _not_ implemented here:
- The type of the function itself currently stays unchanged, that is: we
do not infer a signature with the inferred return type. Concretely:
`my_function()` may be inferred as `int`, but `my_function` itself would
still be inferred as `def my_function() -> Unknown`. We have existing
tests with TODOs for this and I plan to tackle this in a follow up.
- We do not yet have return type inference for generator functions or
async functions
- We do not attempt to solve the problem of "escaping" inner types here
(see tests with TODOs)
- We do not add LSP/playground features related to this feature, but
plan to do so soon (display return type hints).
- There is a limitation around `functools.wraps` that's present in the
ecosystem report. This will hopefully be solved by
https://github.com/python/typeshed/pull/16515
- There is a [limitation around
`NotImplemented`](https://github.com/astral-sh/ty/issues/1150#issuecomment-6140701931)
which hasn't been resolved yet. The solution is described
[here](https://github.com/astral-sh/ty/issues/1150#issuecomment-6147899750).
- Fixpoint iteration currently uses `Divergent` as a provisional value.
I did think about using the new μ-types, but that currently leaves too
many questions unanswered: How would we represent an anonymous μ-type?
How would the (provisional) recursive marker `R` interact with other
types during inference (especially since those operations require
"closed" recursive types, and `R` would still be open)?
closes https://github.com/astral-sh/ty/issues/128
## Test Plan
- Changed assertions in the previously reviewed & merged mdtests
- Studied the ecosystem impact and addressed many problems
---------
Co-authored-by: Shunsuke Shibayama <sbym1346@gmail.com>