Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Unpacking a union of tuples as function args #9475

Closed
alwaysmpe opened this issue Nov 17, 2024 · 2 comments
Closed

Unpacking a union of tuples as function args #9475

alwaysmpe opened this issue Nov 17, 2024 · 2 comments
Labels
enhancement request New feature or request

Comments

@alwaysmpe
Copy link

Is your feature request related to a problem? Please describe.

Given an overloaded function that takes a variable number of one of two types, I'd like a simple way to unpack a union of tuples of those two types to the arguments. Currently it seems they're unpacking to a sequence of the union of contained types. There are relatively simple but cumbersome workarounds, so not a major issue. Example

from typing import overload, cast

@overload
def foo(*arg: int) -> None:...
@overload
def foo(*arg: str) -> None:...
def foo(*arg: int | str):...

# ideal would be something like the below
def constrain_bar[T: (str, int)](arg: tuple[T, ...]):
    # No overloads for "foo" match the provided arguments
    # Argument of type "T@constrain_bar" cannot be assigned to parameter "arg" of type "str" in function "foo"
    #   Type "str* | int*" is not assignable to type "str"
    #     "int*" is not assignable to "str"
    foo(*arg)

def constrain_bar_varg[T: (str, int)](*arg: T):
    # No overloads for "foo" match the provided arguments
    # Argument of type "T@constrain_bar_varg" cannot be assigned to parameter "arg" of type "str" in function "foo"
    #   Type "str* | int*" is not assignable to type "str"
    #     "int*" is not assignable to "str"
    foo(*arg)

# what I'm currently doing
def bar(arg: tuple[int, ...] | tuple[str, ...]):
    # Error - No overloads for "foo" match the provided arguments
    # foo(*arg)

    # this works but is ugly
    if len(arg) == 0:
        foo(*arg)
    elif isinstance(arg[0], int):
        # Type of "arg" is "tuple[int, *tuple[int, ...]] | tuple[str, *tuple[str, ...]]"
        reveal_type(arg)
        arg = cast(tuple[int, ...], arg)
        foo(*arg)
    else:
        # Type of "arg" is "tuple[int, *tuple[int, ...]] | tuple[str, *tuple[str, ...]]"
        reveal_type(arg)
        arg = cast(tuple[str, ...], arg)
        foo(*arg)

# works without problem
def constrain_bar_val[T: (str, int)](arg: T):
    foo(arg)

My actual use case is I'm composing regular expressions from fragments of regular expressions with type constraints to ensure valid composition, so more like the below

from typing import overload, cast

class Str:
    val: str

class StrSeq:
    val: tuple[str, str, *tuple[str, ...]]


type StrT = Str | StrSeq
type LongSeq = tuple[StrT, StrT, *tuple[StrT, ...]]
@overload
def str_seq(*arg: *tuple[StrSeq]) -> StrSeq: ...
@overload
def str_seq(*arg: *LongSeq) -> StrSeq: ...
def str_seq(*arg: StrT) -> StrSeq: ...
@alwaysmpe alwaysmpe added the enhancement request New feature or request label Nov 17, 2024
@erictraut
Copy link
Collaborator

The Python typing spec currently doesn't provide clear guidance for type checkers about how to evaluate call expressions that target an overloaded function. This is unfortunate because it means that library authors cannot use overloads in a way that is guaranteed to work consistently across type checkers.

I have written a draft chapter for the typing spec that covers this functionality. This chapter is still under review by the typing community and has not yet been formally ratified as part of the typing spec. Until the spec has been updated, I don't plan to make any changes to pyright's overload evaluation behaviors because doing so will create churn for pyright users if and when the spec is updated. Once we have an agreed-upon overload evaluation specification, I'll update pyright's logic to conform to the spec.

The current draft proposal would not accommodate your use case. If you would like to advocate for this use case being supported, please respond in the public typing forum discussion thread.

I'll also note that you're using value-constrained type variables in your code, which is something I recommend against. These are not well specified in the Python typing spec, and they have many unexpected and inconsistent behaviors across type checkers. Python is the only language that has something resembling value-constrained type variables, and there's a good reason no other language has adopted this mechanism. If you find yourself tempted to use value-constrained type variables, I recommend trying to find an alternative solution.

@erictraut erictraut closed this as not planned Won't fix, can't repro, duplicate, stale Nov 17, 2024
@alwaysmpe
Copy link
Author

alwaysmpe commented Nov 19, 2024

@erictraut, Thanks very much for your time.

My end goal is to call one overloaded function from another overloaded function with the same overloads. I assume by "value-constrained type variables" you mean my use of T: (str, int), I'd used that because it was the best way I could find to express the type constraints of implementing an overloaded function. For example, the below type-checks correctly in mypy but not pyright:

from typing import overload

@overload
def take_varg(*arg: int) -> int:
    ...
@overload
def take_varg(*arg: str) -> str:
    ...

@overload
def take_varg2(*arg: int) -> int:
    ...
@overload
def take_varg2(*arg: str) -> str:
    ...
def take_varg2[T: (int, str)](*arg: T) -> T:
    return take_varg(*arg)

@overload
def take_pair(arg: int, arg2: int) -> int:
    ...
@overload
def take_pair(arg: str, arg2: str) -> str:
    ...
@overload
def take_pair2(arg: int, arg2: int) -> int:
    ...
@overload
def take_pair2(arg: str, arg2: str) -> str:
    ...
def take_pair2[T: (int, str)](arg: T, arg2: T) -> T:
    return take_pair(arg, arg2)

I've had a look at your proposed change and will add a suggestion about argument type expansion when a function is called from within an overload, as in that case there may only be a subset of argument type expansions which are possible. eg in take_pair2 in the above case, arg, arg2 can only expand to int, int or str, str.

From my perspective the value constrained types behave similarly to c++'s template specialization, an implementation associated with distinct types. The doc section for typing.AnyStr (and its deprecation notice) suggests similar behavior. This is opposed to python's type union which behaves like a generic template without specializations, however your proposal of type expansion somewhat eliminates that distinction.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
enhancement request New feature or request
Projects
None yet
Development

No branches or pull requests

2 participants