Consider assuming that untyped decorators pass through the signature unchanged
Summary
I ran into this problem where ty wouldn't catch a mistyped return type but pyright would. Narrowed it down to a decorator I'm using. Sorry if this is a known issue (a quick search didn't find another open issue for this) or limitation.
Example
def deco(_):
def inner(func):
return func
return inner
@deco(0)
def f() -> str:
return ""
def g() -> float:
return f()
Expected to get "error[invalid-return-type]" (which is indeed what we see if we remove the decorator) but instead got all checks passing.
Version
0.0.2
P.S.: Still loving ty and ruff, keep up the good work!
Thank you for reporting this.
We currently don't support return type inference yet (which is why the return type of deco is just Unknown), but plan to add it soon. See #128 for details.
Note: It's actually not completely clear to me how other type checkers handle this case. It seems to me like we would have to infer a generic ∀ T. Callable[[T], T] type for inner here (and, in effect, for the return type of deco)? But pyright simply infers the type of deco as (_: Unknown) -> ((func: Unknown) -> Unknown). So it looks like it might use additional heuristics (something like: "if the return type of a decorator is Unknown, just pretend it acts like an identity operation")?
Yes, I think I remember some discussion of this. At least pyright (maybe some other type checkers too) do make the assumption that an untyped decorator doesn't change the signature. I'm not sold on this idea, though I can see why it's attractive.
I'm not sure this is really blocked by #128, since I don't think #128 will solve it, nor is necessary to "solve" it in the way that other type checkers do.
Actually this issue is still present if we type hint the decorator (and this was my original issue, I was just trying to create a minimalist example but clearly cut too much out, sorry):
from collections.abc import Callable
def deco[**P, R](_: object) -> Callable[[Callable[P, R]], Callable[P, R]]:
def inner(func: Callable[P, R]) -> Callable[P, R]:
return func
return inner
@deco(0)
def f() -> str:
return ""
def g() -> float:
return f()
Same deal, ty (0.0.2) says all checks passed while pyright (1.1.407) correctly detects a "reportReturnType" (Type "str" is not assignable to return type "float") error.
@afonsotrepa Thanks for the example! It looks like that is a different limitation: https://github.com/astral-sh/ty/issues/1136
@afonsotrepa note also that this version works in all type checkers (including ty) today, because it doesn't rely on the special-cased heuristic in #1136:
from typing import Callable, Protocol
class Identity(Protocol):
def __call__[**P, R](self, fn: Callable[P, R], /) -> Callable[P, R]: ...
def deco(_: object) -> Identity:
def inner[**P, R](func: Callable[P, R]) -> Callable[P, R]:
return func
return inner
@deco(0)
def f() -> str:
return ""
def g() -> float:
return f()
@carljm your Identity class fixed my issue, thanks!
We could also do this in a way that is LSP-specific, by implementing some form of "best-guess types" that we won't use to emit type diagnostics, but will use for LSP purposes.
@carljm sorry for another "off-topic" comment but I found your Identity class was not working for generic functions when using functools.wraps, so instead I'm now using:
def deco[**P, R](_: object) -> Callable[[Callable[P, R]], Callable[P, R]]:
def dec(func: Callable[P, R]) -> Callable[P, R]:
@functools.wraps(func)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
return func(*args, **kwargs)
return wrapper
return dec
This works with ty and pyright and successfully catches invalid return types, even for functions with generics. Hopefully this will be useful for someone else too.