Skip to content

Support function decorators excellently #3157

Closed
@sixolet

Description

@sixolet

Decorators currently stress mypy's support for functional programming to the point that many decorators are impossible to type. I'm intending this issue as a project plan and exploration of the issue of decorators and how to type them. It's a collection point for problems or incompletenesses that keep decorators from being fully supported, plans for solutions to those problems, and fully-implemented solutions to those problems.

Decorators that can only decorate a function of a fixed signature

You can define your decorator's signature explicitly, and it's completely supported:

from typing import Callable, Tuple

def with_strlen(f: Callable[[int], str]) -> Callable[[int], Tuple[str, int]]:
    def ret(__i: int) -> Tuple[str, int]:
        r = f(__i)
        return r, len(r)
    return ret

@with_strlen
def lol(x: int) -> str:
    return "lol"*x

reveal_type(lol)  # E: Revealed type is 'def (builtins.int) -> Tuple[builtins.str, builtins.int]

Notes:

Decorators that do not change the signature of the function

Nearly fully supported. Here's how you do it:

from typing import TypeVar, Callable, cast

T = TypeVar('T')

def print_callcount(f: T) -> T:
    x = 0
    def ret(*args, **kwargs):
        nonlocal x
        x += 1
        print("%d calls so far" % x)
        return f(*args, **kwargs)

    return cast(T, ret)

@print_callcount
def lol(x: int) -> str:
    return "lol"*x

reveal_type(lol)  # E: Revealed type is 'def (x: builtins.int) -> builtins.str'

Notes:

  • Mypy trusts that you can call f. You can set a bound on T to be Callable, but you don't need to for it to typecheck.
  • This business doesn't typecheck without the cast. That's a symptom of the fact that mypy doesn't understand the function nature of the argument to print_callcount, and there's no way to declare that argument as a Callable explicitly without losing the argument type of lol later. We'd like to minimize the places where casts are required. Doing so requires something along the lines of "variadic argument variables", discussed below.

Decorators that take arguments ("second-order"?)

Plenty of decorators "take arguments" by actually being functions that return a decorator. For example, we'd like to be able to do this:

from typing import Any, TypeVar, Callable, cast

T = TypeVar('T')

def callback_callcount(cb: Callable[[int], None]) -> Callable[[T], T]:
    def outer(f: T) -> T:
        x = 0
        def inner(*args, **kwargs):
            nonlocal x
            x += 1
            cb(x)
            return f(*args, **kwargs)
        return cast(T, inner)
    return outer

def print_int(x: int) -> None:
    print(x)

@callback_callcount(print_int)
def lol(x: int) -> str:
    return "lol"*x

reveal_type(lol)  # E: Revealed type is 'def (x: builtins.int) -> builtins.str'

Notes:

Mess with the return type or with arguments

For an arbitrary function you can't do this at all yet -- there isn't even a syntax. Here's me making up some syntax for it.

Messing with the return type

from typing import Any, Dict, Callable

from mypy_extensions import SomeArguments

def reprify(f: Callable[[SomeArguments], Any]) -> Callable[[SomeArguments], str]:
    def ret(*args: SomeArguments.positional, **kwargs: SomeArguments.keyword):
        return repr(f(*args, **kwargs))
    return ret

@reprify
def lol(x: int) -> Dict[str, int]:
    return {"lol": x}

reveal_type(lol)  # E: Revealed type is 'def (x: builtins.int) -> builtins.str'

Messing with the arguments

from typing import Any, Callable, TypeVar

from mypy_extensions import SomeArguments

R = TypeVar('R')

def supply_zero(f: Callable[[int, SomeArguments], R]) -> Callable[[SomeArguments], R]:
    def ret(*args: SomeArguments.positional, **kwargs: SomeArguments.keyword):
        return f(0, *args, **kwargs)
    return ret

@supply_zero
def lol(x: int, y: str) -> str:
    return "%d and %s" % (x, y)

reveal_type(lol)  # E: Revealed type is 'def (y: builtins.str) -> builtins.str'

The syntax here is fungible, but we would need a way to do approximately this thing -- capture the types and kinds of all a function's arguments in some kind of variation on a type variable.

Relevant issues and discussions:

Variadic type variables alone (python/typing#193) get you some of the way there, but lose all keyword arguments of the decorated function.

Things to do:

  • Implement variadic type variables (fill in PR when I have it)
  • Write up detailed proposal for semantics of argument variables
    • ... and how they interact with *args and **kwargs
    • ... and their relationship to variadic type variables and the expand operation
    • ... and the semantics of an easy-to-use SomeArguments-style alias, so nobody has to actually engage with the details of the above when writing normal decorators.
  • Come to some kind of mypy-community consensus or near-consensus on that proposal. It'll be in mypy_extensions not typing at first -- this can be fodder for the future of PEP484, but while we're playing in such experimental land, not yet.
  • PR to implment the SomeArguments thing.

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions