microsoft / pyright

Static Type Checker for Python
Other
13.21k stars 1.42k forks source link

Allow decorators to overshadow a function's type signature #8324

Closed EpicBirb closed 3 months ago

EpicBirb commented 3 months ago
from typing import Callable

def s(func: Callable[[str], None]):
    return func

@s
def a(r):
    ...

The decorated function a should have the parameter r be inferred as str as stated in the decorator s. At the declaration of a, parameter r is of type Unknown. However when we call function a, it does tell us that a requires an object of type str.

The decorator should be able to enforce the type of the decorated function as the decorator requires the function to be of type Callable[[str], None].

erictraut commented 3 months ago

Pyright (the static type checker upon which pylance is based) is working correctly here.

I'm not sure what you mean by "enforce the type of the decorated function". The type of the undecorated function in this case is (r: Any) -> None. The Any comes from the fact that parameter r has no type annotation. The return type None can be inferred. The type of the decorator is (func: Callable[[str], None]) -> Callable[[str], None]. The return type is inferred. When the decorator is applied to the undecorated function, the resulting type is Callable[[str], None]. This is a callable type that accepts one positional argument that is compatible with str.

If you enable type checking in pylance (i.e. set typeCheckingMode to "basic" or "standard") and attempt to call a with an argument with an incompatible type, you will see a error from the type checker.

Code sample in pyright playground

from typing import Callable

def s(func: Callable[[str], None]):
    return func

@s
def a(r):
    ...

a("")  # No error
a(1)  # Type error
EpicBirb commented 3 months ago

Allow me to clear up a misunderstanding here. I know that calling the function will use type Callable[[str], None]. But at the declaration of function a (provided in the example above), r is presumed as Unknown by the type checker, however we know for a fact that the decorator asks for the function to be of type Callable[[str], None]. So why can't it infer the argument r at declaration to be of type str and not Unknown. Example:

image Hovering over r, I should expected it to be type annotated as str, not Unknown

erictraut commented 3 months ago

Static type analysis doesn't work that way. Inferring the type of an argument from a function call would be backward. Static type checkers can infer the return type of a call expression from the types of the arguments and the signature of the callee, but the converse (inferring the types of arguments from the call) isn't generally possible.

A decorator is just a special syntax for a call expression. Your code is effectively equivalent to:

def _a(r): ...
a = s(_a)

An expression (such as s(_a)) is first translated to a parse tree where each node represents an operator and has references to its subexpressions. When evaluating the type of an expression, a type checker starts with the leaf nodes of this parse tree and evaluates those types first. Then it successively evaluates the types of the expressions that depend on those subexpressions until it evaluates the type of the entire tree. During this process, it reports any "type violations" — i.e. types of operands that are not allowed based on the operator type of an expression.

When a call expression (such as s(_a)) is statically evaluated, the types of its argument expressions are evaluated first. The type checker then verifies that these argument types match the signature of the callee's signature. Call signatures can be very complex in Python — e.g. with *args and **kwargs parameters, positional-only and keyword-only parameters, default (optional) values, and overloads.

Deriving the type of an argument expression based on the signature of the call is not generally possible for a number of reasons. For one, there are many types that could be compatible with the callee's signature. For example, a could be (str) -> None or (object) -> None. Both of these would satisfy the type constraints of s. And this is a toy sample. In real-world examples, the number of compatible argument types would be immense — effectively infinite. Guessing which one the programmer intended would be computationally expensive and produce the wrong guess much of the time.

In your code sample, if your intent is for parameter r in function a to be limited to type str (or a subclass thereof), you should add an explicit type annotation for r indicating as such. That's the purpose of static type annotations in Python. A static type checker like pyright will then verify that the decorator you apply to this function is compatible with it.

Pyright is a standards-based type checker, and it conforms to the Python typing spec.

If you want to understand more about how static type analysis and type checking works, here are some documentation sources that you may find useful: Pyright Documentation Python Type Checking Specification