microsoft / pyright

Static Type Checker for Python
Other
13.37k stars 1.46k forks source link

functools.cache misinterprets Literals #3426

Closed zenbot closed 2 years ago

zenbot commented 2 years ago

pyright 1.1.244 appears to interpret functions decorated with functools.cache (& functools.lru_cache) that return Literals as returning the less specific non-literal type instead. The following code reproduces this:

import functools
from typing import Literal

@functools.cache
def cached_literal_str() -> Literal["Foo"]:
    return "Foo"

target: Literal["Foo"] = cached_literal_str()

This code should pass type checking (& mypy is happy with it), but instead produces the following error in pyright:

error: Expression of type "str" cannot be assigned to declared type "Literal['Foo']"
MathiasSven commented 2 years ago

It works with mypy because it naively resolves the type variable to a literal, however when you are defining a function with a type variable no such literal type constraints are taken into consideration, in fact without a more expressive type system resolving type variables to literals seems sort of useless:

from typing import *

T = TypeVar('T')

def mk_instance(cls: type[T]) -> T:
    return cls()

def foo(a: T) -> T:
    return mk_instance(type(a))

num: Literal[5] = 5

reveal_type(foo(num))  # Revealed type is "Literal[5]" // 0 at runtime
erictraut commented 2 years ago

Pyright (and I presume mypy) uses various heuristics to determine when to retain a literal and when to widen the type to its non-literal counterpart. These heuristics have been tuned over time to produce expected behaviors most of the time while avoiding false positive errors. Tuning these heuristics is challenging because fixing one case can break others.

For the code sample at the top of this issue, it seems logical (at least to me) that the literal type would be preserved in this case. I'll investigate this particular case further to see if I can adjust the heuristics accordingly without affecting other use cases.

@MathiasSven, there are definitely cases where it's useful (and even necessary) to retain literals in the TypeVar constraint solver. The example you provided is highly contrived, so I wouldn't optimize for that use case.

MathiasSven commented 2 years ago

I apologize, I meant to say that resolving type variables across a type signature (arg, return) seems pointless as currently neither type checker does any checking to make sure the implementing function actually retain the same literal. The same example can be reproduced with simple literal manipulation inside functions, such as def foo(a: SupportsAddT) -> SupportsAddT: return a + 5, I only brought this up because this has been a long bug I noticed in mypy, but maybe even then optimistically inferring literal across the arrow might have overweighting advantages as you mentioned.

erictraut commented 2 years ago

Yeah, that's a good point. Thanks for clarifying.

erictraut commented 2 years ago

As I anticipated, my first attempt to adjust this heuristic broke some other important type evaluation cases. The solution I implemented is to have the TypeVar constraint solver retain literals for the return type of a Callable if the source function has a declared return type with a Literal. If the source function has an inferred return type that includes a Literal, the literals are not retained by the constraint solver in that case.

This change will be included in the next release.

@MathiasSven, I think you are correct to point out a potential type safety hole, but this is a case where practicality is more important than purity. It's reasonable to expect that decorators like functools.cache will preserve the return type of the function they are decoratoring, even when that type includes a Literal.

erictraut commented 2 years ago

This is addressed in pyright 1.1.245, which I just published. It will also be included in the next release of pylance.