Skip to content

Proposal: Generalize Callable to be able to specify argument names and kinds #264

Closed

Description

@sixolet

Right now you can specify callables with two patterns of arguments (shown here by example):

  • Callable[..., int] takes in any arguments, any number.
  • Callable[[int, str, bool], int] takes in a predetermined number of required positional arguments, none of which have names specified.

These don't cleanly match the actual types of callable objects in Python. Argument names, whether arguments are optional, and whether arguments are *args or **kwargs do affect the type of a callable. We should be able to spell these things in the type language. Doing so would enable us to correctly write the types of callback functions, for example.

Callable should take two arguments: an argument list and a return type. The return type is exactly as currently described in PEP484. The argument list is either:

  • ..., indicating the function can take any arguments at all.
  • Square brackets around a comma separated series of argument specifiers (argspec for short), indicating particulars about the functions arguments

An argument specifier is one of:

  • A bare type TYP. This has the same meaning as Arg(TYP)
  • Arg(type, name=None), indicating a positional argument. If the name is specified, the argument must have that name.
  • OptionalArg(type, name=None), indicating an optional positional argument. If the name is specified, the argument must have that name. (alternate name possibility OptArg(type, name=None)
  • StarArg(type), indicating a "star argument" like *args
  • KwArg(type), indicating a "double star argument" like **kwargs. (an alternate name here would be Star2Arg(type).

The round parens are an indication that these are not actual types but rather this new argument specifier thing.

Like the rules for python function arguments, all positional argspecs must come before all optional argspecs must come before zero or one star argspecs must come before zero or one kw argspecs.

This should be able to spell all function types you can make in python by defining single functions or methods, with the exception of functions that need SelfType to be properly specified, which is an orthogonal concern.

Some statements I think are true:

  • Callable[[Arg(T1, name='foo'), Arg(T2, name='bar')], R] is a subtype of Callable[[T1, T2], R]
  • Callable[[T1, OptionalArg(T2)], R] is a subtype of Callable[[T1], R]
  • Callable[[StarArg(T1)], R] is a subtype of Callable[[], R] and is also a subtype of Callable[[T1], R] and is also a subtype of Callable[[T1, T1], R] and so on.
  • Callable[[T1, StarArg(T1)], R] is a subtype of Callable[[T1], R] and is also a subtype of Callable[[T1, T1], R] and so on, but is not a subtype of Callable[[], R]
  • If we want to be able to spell overloaded function types we'll need a specific way of combining callable types in the specific way overloading works; this proposal doesn't address that.

Activity

  1. JukkaL commented on Aug 19, 2016

    @JukkaL
    Contributor

    Another thing to consider is keyword-only arguments in Python 3. Keyword-only arguments can be optional or non-optional. Maybe use Arg(t, name='foo', keyword_only=True), and similarly for OptionalArg?

  2. sixolet commented on Aug 19, 2016

    @sixolet
    Author

    @JukkaL Yep seems good.

  3. ilevkivskyi commented on Aug 19, 2016

    @ilevkivskyi
    Member

    @sixolet Thankyou for your proposal!
    I proposed a similar idea in #239 based on inspect.Signature and Guido rejected it in favor of function stubs. His argument (and I agree with him) is readability. Compare:

    def cb_stub(x: T, *args, option: str='verbose') -> int:
        raise NotImplementedError
    
    def func(callback: Callable[cb_stub]):
        ...

    and

    Args = [Arg(T, name='x'), StarArg(Any), OptionalArg(str, name='option')]
    
    def func(callback: Callable[Args, int]):
        ...

    I think the first one is more readable and allows to quickly grasp the function signature. Also it is not clear from your proposal how to type annotate a decorator that preserves the types of all arguments in *args (propbably you also need to discuss how this interacts with variadic generics etc).

    I would like to reiterate, function stubs together with OtherArgs discussed in #239 cover vast majority of use cases, so that I think practicality beats purity here.

    @JukkaL what do you think about the magic (variadic) type variable OtherArgs? I copy here use cases in decorators from previous discussions:

    from typing import OtherArgs, TypeVar, Callable
    R = TypeVar('R')
    
    def change_ret_type(f: Callable[[OtherArgs], R]) -> Callable[[OtherArgs], int]: ...
    
    def add_initial_int_arg(f: Callable[[OtherArgs], R]) -> Callable[[int, OtherArgs], R]: ...
    
    def fix_initial_str_arg(f: Callable[[str, OtherArgs], R]) -> Callable[[OtherArgs], int]:
        def ret(*args, **kwargs):
            f('Hello', *args, **kwargs)
        return ret
  4. sixolet commented on Aug 19, 2016

    @sixolet
    Author

    Regarding OtherArgs, I think it's separable and so we should consider it separately; being able to spell argument names and kinds is useful in the absence of such a thing. I'll write it up separately once we know what the fate of variadic type variables is likely to be (since the exact definition/meaning of OtherArgs depends on whether we can explain it with variadic typevars).

  5. sixolet commented on Aug 19, 2016

    @sixolet
    Author

    It's true that this proposal is similar to your proposal based on inspect.Signature, but it differs in a few important ways:

    • It's more concise and readable (I know, a "simple matter of syntax", but I think it actually makes a difference here). It's concise enough that you're likely to spell the whole type of a callable in just one line.
    • It's exactly the same as the current syntax in the common/simple case. This also aids readability.
    • It's specialized to the purpose of specifying what arguments are like, instead of being intended for runtime introspection and dynamic function creation/metaprogramming. "Do one job well" is UNIX philosophy, not python philosophy, but I think it applies here.
  6. JukkaL commented on Aug 19, 2016

    @JukkaL
    Contributor

    @ilevkivskyi I agree with @sixolet that OtherArgs should be discussed separately from this. This proposal (or the alternative proposed syntax) would be useful as such. Callbacks with other than positional arguments have been a pretty common mypy feature request.

  7. ilevkivskyi commented on Aug 19, 2016

    @ilevkivskyi
    Member

    OK, if you want to proceed with this, I would propose to try to make it more concise. Here are some tweaks, apart from already mentioned OpArgs:

    • make name a first argument,
    • it could be positional,
    • no one probably will use Arg(T) it is just equivalent to T, so that better make the type optional:
    Arg('x', T)
    Arg('y') # same as Arg('y', Any)
    • probably use shorter keyword for keyword only kw_only=True,
    • allow omitting kw_only in obvious places, e.g., after StarArg,
    • missing type in StarArg and KwArg should also mean Any (like actually everywhere in PEP 484, missing type means Any).

    With all these tweaks it will look like this:

    def fun1(x: List[T], y: int = 0, *args, **kwargs: str) -> None:
        ...

    has type

    Callable[[Arg('x', List[T]), OptArg('y', int), StarArg(), KwArg(str)], None]

    Second example:

    def fun2(__s: str, __z: T, *, tmp: str, **kwargs) -> int:
        ...

    has type

    Callable[[str, T, Arg('tmp', str, kw_only=True), KwArg()], int]

    It looks more concise, and still readable. What do you think?

  8. sixolet commented on Aug 19, 2016

    @sixolet
    Author

    @ilevkivskyi I like most of that.

    The only bit I think I disagree with is

    allow omitting kw_only in obvious places, e.g., after StarArg

    And that's because I think in this particular case consistency is more important than brevity.

    Huh. Can we omit the parens when they're not being used for anything? Why not

    Callable[[str, T, Arg('tmp', str, kw_only=True), KwArg], int]
  9. sixolet commented on Aug 19, 2016

    @sixolet
    Author

    (Answering my own question, the round parens remind us it's an argspec not a type. Right.)

  10. ilevkivskyi commented on Aug 19, 2016

    @ilevkivskyi
    Member

    @sixolet OK
    Now that you have +1 from Jukka, you should get an approval from Guido and then add this to the PEP and typing.py.

    By the way I was thinking a bit about how this will interoperate with variadic generics, and I think the solution is simple: StarArg and KwArg should be allowed to accept them as an argument and that's it. Then you could easily type a decorator with something like

    As = TypeVar('As', variadic=True)
    Ks = TypeVar('Ks', variadic=True)
    
    Input = Callable[[int, StarArg(As), KwArg(Ks)], None]
    Output = Callable[[StarArg(As), KwArg(Ks)], None]
    Deco = Callable[[Input], Output]

    Moreover OtherArgs could be easily defined as [StarArg(As), KwArg(Ks)], so that one can write (unpacking just unpacks two items here):

    Input = Callable[[int, *OtherArgs], None]
    Output = Callable[*OtherArgs], None]

    But now it is probably not needed in the specification. So that now I give your proposal a solid +1.

  11. elazarg commented on Oct 21, 2016

    @elazarg

    I don't understand what's wrong with something like Callable[lambda a: int, *, b: str =...:...]

    It will be much easier for type checkers to learn, and the meaning is obvious. Why invent new ways of saying the same thing?

  12. JukkaL commented on Oct 21, 2016

    @JukkaL
    Contributor

    It's not syntactically valid. Lambdas can't have type annotations.

  13. sixolet commented on Oct 25, 2016

    @sixolet
    Author

    @Lazarg Also, we should be careful to avoid confusing people regarding "how do you write a function" vs "how do you write the type of a function"

  14. elazarg commented on Oct 25, 2016

    @elazarg

    The type (the interface) of the function is the thing that appears right after its name - the signature.

  15. elazarg commented on Oct 25, 2016

    @elazarg

    Oh. Consequences of using : instead of => ?

    That's very unfortunate.

  16. 21 remaining items

  17. devxpy commented on Nov 7, 2018

    @devxpy

    @ilevkivskyi This does not seem to work with *args and **kwargs.

    Am I doing something wrong?

    from typing import Callable, Any
    from typing_extensions import Protocol
    
    
    
    class ApplesWrapper(Protocol):
        def __call__(self, fruits: dict, *args, **kwargs) -> Any: ...
    
    
    def print_apples(fn: ApplesWrapper) -> ApplesWrapper:    
        def wrapper(fruits: dict, *args, **kwargs):
            print('Apples:', fruits['apples'])
    
            return fn(fruits, *args, **kwargs)
    
        return wrapper
    
    @print_apples
    def get(fruits: dict, key):
        return fruits[key]
    $ mypy test.py
    test.py:18: error: Argument 1 to "print_apples" has incompatible type "Callable[[Dict[Any, Any], Any], Any]"; expected "ApplesWrapper"
    
  18. ilevkivskyi commented on Nov 7, 2018

    @ilevkivskyi
    Member

    @Seanny123 Now that we have callback protocols, the PEP will be less valuable (if needed at all). The only missing thing is variadic generics, but this is a separate question, and the problem with them is not who will write the PEP, but who will implement it.

    @devxpy Well, mypy correctly says that type of get() is not a subtype of ApplesWrapper, there are many possible calls to ApplesWrapper that will fail on get().

    I think what you want is OtherArgs proposed in #239 and also discussed a bit above. This is more about variadic generics which, as I explained above, is a separate issue.

  19. JukkaL commented on May 30, 2019

    @JukkaL
    Contributor

    Should we close this issue now that callback protocols are described in PEP 544 (which was accepted)?

  20. gvanrossum commented on May 30, 2019

    @gvanrossum
    Member

    I seem to be in the minority, but I find callback protocols too verbose, and not very intuitive.

    I'd rather have a decorator you can put on a dummy def. E.g. instead of

    class ApplesWrapper(Protocol):
        def __call__(self, fruits: dict, *args, **kwargs) -> Any: ...

    I'd prefer

    @some_decorator
    def ApplesWrapper(fruits: dict, *args, **kwargs) -> Any: ...

    My reasons are hard to explain -- I find the need to use class, Protocol, __call__ and self for the current solution rather noisy.

  21. JukkaL commented on May 31, 2019

    @JukkaL
    Contributor

    I agree that the current syntax is overly verbose. On the other hand complex callables don't seem to be required more than occasionally by most programmers. Maybe somebody will come up with a nicer and more concise syntax, though.

  22. antonagestam commented on May 31, 2019

    @antonagestam

    To find similarity to another thing within the language, async def, it might be interesting to explore a "def" keyword like below. Since the spec can't have a body, the colon and ellipsis/pass should be omitted in my opinion.

    argspec def ApplesWrapper(fruits: dict, *args, **kwargs) -> Any

    Or, if we'd like to be even less verbose with an even shorter syntax, making this it's entirely "own" thing.

    argspec ApplesWrapper(fruits: dict, *args, **kwargs) -> Any
    
  23. Michael0x2a commented on May 31, 2019

    @Michael0x2a
    Contributor

    Maybe we could add in some decorator that when present will promote some function into a callable protocol? Basically, make it so that type checkers treat Guido's second example as a shorthand for the first.

    The decorator could just be the identity function at runtime. We could also make it literally construct the protocol class if the distinction between a function vs a callable object is important to preserve.

    At least for mypy, I feel we could pretty easily implement this via the plugin system.

  24. jdelic commented on May 31, 2019

    @jdelic

    @antonagestam
    I haven't fully thought this through, but right now I like your idea... as it also specifies return types and not just args I would go for

    typed def callback(a: str) -> bool:
        ...
    # or
    typespec callback(...) -> bool:
        ...
    # or
    typedef callback(...) -> bool:
        ...

    instead of argspec.

    And then only declare the implementation's intention of fulfilling the contract using a decorator:

    @typeimpl(callback)
    def mycallback(...) -> bool:
        ...

    As the language already introduced class variable annotations and return type annotations I'd find a syntax extension more natural for "first class support" of function types than a decorator syntax that leads to typing being implemented counter-intuitively through a mixture of new language keywords and special code.

  25. antonagestam commented on May 31, 2019

    @antonagestam

    @jdelic As for naming of this hypothetical keyword, would signature be even better? I agree that argspec is not an accurate name.

    signature Greet(name: str) -> str

    (I hope this is not the wrong forum for hypothetical discussions like this)

  26. gvanrossum commented on May 31, 2019

    @gvanrossum
    Member

    Note that such syntactical changes are hard to get accepted -- adding a reserved word to Python requires a PEP, a from __future__ import ... statement, and several releases of deprecation warnings. (That's what it cost to introduce async def and await.)

    In contrast, a decorator is trivial to add -- you can just import it.

  27. ilevkivskyi commented on Jun 2, 2019

    @ilevkivskyi
    Member

    I am fine with the current way. Some reasons:

    • It already works
    • There should be only one way to do it
    • It is not an ad-hoc solution, and therefore is easy to understand after one understood protocols in general
    • Some people don't like camel case in def
    • Finally, IMO the current way looks better for generics. One can easily indicate the binding of type variables explicitly. For example these are two different things:
    class One(Protocol):
        def __call__(self, x: T) -> T: ...
    class Other(Protocol[T]):
        def __call__(self, x: T) -> T: ...

    with the proposed syntax this would require supporting something like:

    @some_decorator(bind=(T,))
    def Other(x: T) -> T: ...

    and add some tricky runtime machinery to allow Other[int]. I would propose to just close this and move on. I think there are more important things to do than minor syntactic improvements for relatively rare use cases.

  28. ambv commented on Nov 11, 2019

    @ambv
    Contributor

    I always sided with the line of thinking described by Guido here. I didn't even think we'd need a special decorator for it. I always thought of function objects as "types with callable signatures". I've seen many cases where being able to pass an example function as a type would make things clearer instead of using Callable[].

    In practice I don't mind specifying a Protocol with __call__() much but the requirement to add self as the first argument there adds confusion on top of the overall verbosity of this.

  29. gvanrossum commented on Nov 11, 2019

    @gvanrossum
    Member

    It's a tough call. In the end I think we should just try to live with the Protocol solution for a while before we decide that it's too onerous. So I'm closing this.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions