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

Support signal parameter/argument types #68

Open
altendky opened this issue Sep 20, 2020 · 10 comments
Open

Support signal parameter/argument types #68

altendky opened this issue Sep 20, 2020 · 10 comments
Labels
enhancement New feature or request

Comments

@altendky
Copy link
Collaborator

So I don't know that this pans out but here's a very preliminary proof of concept that my initial thoughts were wrong and this is at least vaguely possible without a mypy plugin. It doesn't deal with the variadic nature of signal parameters nor does it make any attempt at #9. Anyways, I just wrote it up out of curiosity so figured I'd share it here. Though it may still require a mypy plugin to actually get it working right. Oh yeah, also doesn't cover connecting to a proper 'slot'.

Based on the signal classes in #56.

https://mypy-play.net/?mypy=latest&python=3.8&flags=strict&gist=fb8771c87a891edcfa30d522ae588638

import typing


TA = typing.TypeVar("TA")
TB = typing.TypeVar("TB")


class pyqtBoundSignal(typing.Generic[TA, TB]):
    signal: str = ""

    def emit(self, a: TA, b: TB) -> None: ...


class pyqtSignal(typing.Generic[TA, TB]):
    def __init__(self, a: typing.Type[TA], b: typing.Type[TB], *, name: str = ...) -> None: ...

    @typing.overload
    def __get__(self, instance: None, owner: object) -> "pyqtSignal": ...
    @typing.overload
    def __get__(self, instance: object, owner: object) -> pyqtBoundSignal[TA, TB]: ...
    
    def __get__(self, instance, owner):
        # mypy-play doesn't seem to offer a 'stub' option
        pass


class D:
    signal = pyqtSignal(int, str)


d = D()
d.signal.emit(1, "s")
d.signal.emit(1, 2)
main.py:33: error: Argument 2 to "emit" of "pyqtBoundSignal" has incompatible type "int"; expected "str"
Found 1 error in 1 file (checked 1 source file)
@altendky
Copy link
Collaborator Author

Allow variadic generics: python/typing#193

@altendky altendky added the enhancement New feature or request label Sep 20, 2020
@BryceBeagle
Copy link
Collaborator

BryceBeagle commented Sep 20, 2020

I really like this idea. However, I'm struggling to wrap my head around how we'll generify the difference between the stored signatures of a pyqtBoundSignal and the signatures of its .emit method

from PyQt5.QtCore import QObject, pyqtSignal

class Test(QObject):
    valueChanged = pyqtSignal([dict, str], [list])

#### unbound. Note the repetitious indexing for the example
list_signal = Test.valueChanged[list][dict, str][list]
dict_str_signal = Test.valueChanged[list][dict, str]

# Will be the same. Both keep information about all signatures
assert list_signal.signatures == dict_str_signal.signatures


### bound
a = Test()

list_signal = a.valueChanged[list][dict, str][list]
dict_str_signal = a.valueChanged[list][dict, str]

# Allowed
a.valueChanged.emit({"b": "c"}, "test")    # default signature
list_signal.emit([1, 2, 3])                # forced signature
dict_str_signal.emit({"b": "c"}, "test")   # forced signature

# Not allowed.
a.valueChanged.emit([1, 2, 3])             # wrong usage of default signature
list_signal.emit({"b": "c"}, "test")       # wrong usage of forced signature
dict_str_signal.emit([1, 2, 3])            # wrong usage of forced signature

In the example above, the pyqtBoundSignal knows about all of the possible signatures it can support, but .emit only supports one of them at a time.

How can we make this work?

@altendky
Copy link
Collaborator Author

altendky commented Sep 21, 2020

Yeah, I've got no theories outside a plugin as to how you track the multiple overloaded signal signatures. But, I suspect that the majority of uses are with not-overloaded signals and even with them we could default to the... default overload. Maybe that's ok with some explanation of it and users can just ignore in other cases?

I think I faked a hacky sorta-kinda-variadic solution for the not-overloaded-signatures part.

https://mypy-play.net/?mypy=latest&python=3.8&gist=db80a939bf2224d5f6f95555c28d287a

import typing


TA = typing.TypeVar("TA")
TB = typing.TypeVar("TB")


class pyqtBoundSignal(typing.Generic[TA, TB]):
    signal: str = ""

    @typing.overload
    def emit(self) -> None: ...
    @typing.overload
    def emit(self, a: TA) -> None: ...
    @typing.overload
    def emit(self, a: TA, b: TB) -> None: ...

    def emit(self, a: TA = ..., b: TB = ...) -> None: ...


class _ParameterNotAllowed: ...


class pyqtSignal(typing.Generic[TA, TB]):
    @typing.overload
    def __new__(self, *, name: str = ...) -> "pyqtSignal[_ParameterNotAllowed, _ParameterNotAllowed]": ...
    @typing.overload
    def __new__(self, a: typing.Type[TA], *, name: str = ...) -> "pyqtSignal[TA, _ParameterNotAllowed]": ...
    @typing.overload
    def __new__(self, a: typing.Type[TA], b: typing.Type[TB], *, name: str = ...) -> "pyqtSignal[TA, TB]": ...

    def __new__(
        self,
        a: typing.Union[typing.Type[TA], typing.Type[_ParameterNotAllowed]] = _ParameterNotAllowed,
        b: typing.Union[typing.Type[TB], typing.Type[_ParameterNotAllowed]] = _ParameterNotAllowed,
        *,
        name: str = ...,
    ) -> "pyqtSignal": ...

    @typing.overload
    def __get__(self, instance: None, owner: object) -> "pyqtSignal[TA, TB]": ...
    @typing.overload
    def __get__(self, instance: object, owner: object) -> pyqtBoundSignal[TA, TB]: ...
    
    def __get__(self, instance: object, owner: object) -> typing.Union["pyqtSignal[TA, TB]", pyqtBoundSignal[TA, TB]]: ...


class D:
    zero = pyqtSignal()
    one = pyqtSignal(list)
    two = pyqtSignal(int, str)


d = D()

# good
d.zero.emit()
d.one.emit([])
d.two.emit(1, "s")

# bad
d.zero.emit([])
d.zero.emit(1, "s")
d.one.emit("")
d.one.emit(1, "s")
d.two.emit(1, 2)
main.py:62: error: No overload variant of "emit" of "pyqtBoundSignal" matches argument type "List[<nothing>]"
main.py:62: note: Possible overload variant:
main.py:62: note:     def emit(self, a: _ParameterNotAllowed) -> None
main.py:62: note:     <2 more non-matching overloads not shown>
main.py:63: error: No overload variant of "emit" of "pyqtBoundSignal" matches argument types "int", "str"
main.py:63: note: Possible overload variant:
main.py:63: note:     def emit(self, a: _ParameterNotAllowed, b: _ParameterNotAllowed) -> None
main.py:63: note:     <2 more non-matching overloads not shown>
main.py:64: error: No overload variant of "emit" of "pyqtBoundSignal" matches argument type "str"
main.py:64: note: Possible overload variant:
main.py:64: note:     def emit(self, a: List[Any]) -> None
main.py:64: note:     <2 more non-matching overloads not shown>
main.py:65: error: No overload variant of "emit" of "pyqtBoundSignal" matches argument types "int", "str"
main.py:65: note: Possible overload variant:
main.py:65: note:     def emit(self, a: List[Any], b: _ParameterNotAllowed) -> None
main.py:65: note:     <2 more non-matching overloads not shown>
main.py:66: error: No overload variant of "emit" of "pyqtBoundSignal" matches argument types "int", "int"
main.py:66: note: Possible overload variant:
main.py:66: note:     def emit(self, a: int, b: str) -> None
main.py:66: note:     <2 more non-matching overloads not shown>
Found 5 errors in 1 file (checked 1 source file)

For the find-children stuff I was exploring I wrote up a silly little typevar/overload generator to fill part of the no-variadic-generics hole. It could be added to the source with some cog. (I keep saying I'll use cog but somehow I still haven't...) So we could then generate code to handle up to, say, 20 signal parameters with proper hinting. Maybe.

https://repl.it/@altendky/CalmSickDistributeddatabase-1

import string


upper_letters = ['T' + c for c in string.ascii_uppercase]


def create_typevars(
    names=upper_letters,
    typevar='typing.TypeVar',
    bound=None,
):
    if bound is None:
        bound_argument = ''
    else:
        bound_argument = f', bound={bound}'
    return [
        f'{name} = {typevar}("{name}"{bound_argument})'
        for name in names
    ]


def main():
    names = upper_letters[:4]
    print('\n'.join(create_typevars(names=names, bound='QObject')))

    template = 'def findChildren(self, types: typing.Tuple[{0}], name: str = ..., options: typing.Union[Qt.FindChildOptions, Qt.FindChildOption] = ...) -> typing.List[typing.Union[{0}]]: ...'

    for index in range(len(names)):
        print(template.format(', '.join(names[:index + 1])))


main()
TA = typing.TypeVar("TA", bound=QObject)
TB = typing.TypeVar("TB", bound=QObject)
TC = typing.TypeVar("TC", bound=QObject)
TD = typing.TypeVar("TD", bound=QObject)
def findChildren(self, types: typing.Tuple[TA], name: str = ..., options: typing.Union[Qt.FindChildOptions, Qt.FindChildOption] = ...) -> typing.List[typing.Union[TA]]: ...
def findChildren(self, types: typing.Tuple[TA, TB], name: str = ..., options: typing.Union[Qt.FindChildOptions, Qt.FindChildOption] = ...) -> typing.List[typing.Union[TA, TB]]: ...
def findChildren(self, types: typing.Tuple[TA, TB, TC], name: str = ..., options: typing.Union[Qt.FindChildOptions, Qt.FindChildOption] = ...) -> typing.List[typing.Union[TA, TB, TC]]: ...
def findChildren(self, types: typing.Tuple[TA, TB, TC, TD], name: str = ..., options: typing.Union[Qt.FindChildOptions, Qt.FindChildOption] = ...) -> typing.List[typing.Union[TA, TB, TC, TD]]: ...

@BryceBeagle
Copy link
Collaborator

BryceBeagle commented Sep 21, 2020

Don't forget that pyqtSignal.__new__ (__init__?) takes either standalone types or lists of types. This is sure to add more boilerplate.

These two should be equivalent.

a = pyqtSignal([int, str])
b = pyqtSignal(int, str)

@altendky
Copy link
Collaborator Author

Quite so, thanks for pointing that out. It was suggested that perhaps instead of a plugin we just write a wrapper of sorts that people could use instead when they want detailed hints. Also, a mypy plugin would only work with things that use mypy so it's not really an end-all solution anyways.

So, the cost of the above partial solution (aside from being a hack) is that it would get in the way of the not-handled cases and make them extra ugly. Perhaps that's sufficient to rule it out. Maybe I can work out what a wrapper that is more Python and typing friendly would look like.

@altendky
Copy link
Collaborator Author

altendky commented Jan 1, 2021

https://bugreports.qt.io/browse/PYSIDE-1318?focusedCommentId=546023&page=com.atlassian.jira.plugin.system.issuetabpanels%3Acomment-tabpanel#comment-546023

That's a generate-classes-for-each-signal approach to getting overloads and connect/disconnect/emit signatures that aren't totally generic. Not something I expect us to implement here, but linking for completeness.

(direct link to mypy-play example: https://mypy-play.net/?mypy=latest&python=3.9&gist=95795bc6793a6d4efa0ac061c5d93701)

@mrahtz
Copy link

mrahtz commented Feb 20, 2021

@altendky I noticed you referenced python/typing#193 on variadic generics in this thread. Heads up that we've been working on a draft of a PEP for this in PEP 646. If this is something you still care about, take a read and let us know any feedback in this thread in typing-sig. Thanks!

@altendky
Copy link
Collaborator Author

@mrahtz, thanks for the heads up. I read through it and it does seem like it would help out with one aspect of the effort here. Signals support all sorts of further stuff like multiple signatures on one signal, connecting to slots which accept only some of the arguments, and I think more. I understand you may not have time to look over every attempt people make to apply the PEP, but if you are interested, see the second example below.

For everyone else, here's an update to the non-variadic example including the various changes to the signal classes etc.

https://mypy-play.net/?mypy=latest&python=3.8&flags=strict&gist=8ca3a353d2c1c5c100c1eb2d7db6ae79

import typing


TA = typing.TypeVar("TA")
TB = typing.TypeVar("TB")


class pyqtBoundSignal(typing.Generic[TA, TB]):
    signal: str = ""

    def __getitem__(self, key: object) -> "pyqtBoundSignal[TA, TB]": ...

    def emit(self, a: TA, b: TB) -> None: ...
    def connect(self, slot: "PYQT_SLOT") -> "QMetaObject.Connection": ...
    @typing.overload
    def disconnect(self) -> None: ...
    @typing.overload
    def disconnect(self, slot: typing.Union["PYQT_SLOT", "QMetaObject.Connection"]) -> None: ...

    def disconnect(self, slot: typing.Union["PYQT_SLOT", "QMetaObject.Connection"] = ...) -> None:
        # mypy-play doesn't seem to offer a 'stub' option
        pass

class pyqtSignal(typing.Generic[TA, TB]):

    signatures: typing.Tuple[str, ...] = ('',)

    def __init__(self, a: typing.Type[TA], b: typing.Type[TB], *, name: str = ...) -> None: ...

    @typing.overload
    def __get__(self, instance: None, owner: typing.Type["QObject"]) -> "pyqtSignal[TA, TB]": ...
    @typing.overload
    def __get__(self, instance: "QObject", owner: typing.Type["QObject"]) -> pyqtBoundSignal[TA, TB]: ...
    
    def __get__(self, instance: typing.Optional["QObject"], owner: typing.Type["QObject"]) -> typing.Union["pyqtSignal[TA, TB]", pyqtBoundSignal[TA, TB]]:
        # mypy-play doesn't seem to offer a 'stub' option
        pass


# Convenient type aliases.
PYQT_SLOT = typing.Union[typing.Callable[..., object], pyqtBoundSignal]

class QObject:
    pass

class QMetaObject:
    class Connection:
        pass

class D(QObject):
    signal = pyqtSignal(int, str)


reveal_type(D.signal)
d = D()
reveal_type(d.signal)
d.signal.emit(1, "s")
d.signal.emit(1, 2)

And also a try at using the variadic feature described in PEP 646. I didn't see a Mypy PR for this so I didn't make any attempt to actually test it.

import typing


Ts = typing.TypeVarTuple("Ts")


class pyqtBoundSignal(typing.Generic[typing.Unpack[Ts]]):
    signal: str = ""

    def __getitem__(self, key: object) -> "pyqtBoundSignal[typing.Unpack[Ts]]":
        ...

    def emit(self, *args: typing.Unpack[Ts]) -> None:
        ...

    def connect(
        self,
        slot: typing.Union[
            typing.Callable[[typing.Unpack[Ts]], object],
            pyqtBoundSignal[typing.Unpack[Ts]],
        ],
    ) -> "QMetaObject.Connection":
        ...

    @typing.overload
    def disconnect(self) -> None:
        ...

    @typing.overload
    def disconnect(
        self, slot: typing.Union["PYQT_SLOT", "QMetaObject.Connection"]
    ) -> None:
        ...

    def disconnect(
        self, slot: typing.Union["PYQT_SLOT", "QMetaObject.Connection"] = ...
    ) -> None:
        # mypy-play doesn't seem to offer a 'stub' option
        pass


class pyqtSignal(typing.Generic[typing.Unpack[Ts]]):

    signatures: typing.Tuple[str, ...]

    def __init__(self, *args: typing.Unpack[Ts], name: str = ...) -> None:
        ...

    @typing.overload
    def __get__(
        self, instance: None, owner: typing.Type["QObject"]
    ) -> "pyqtSignal[typing.Unpack[Ts]]":
        ...

    @typing.overload
    def __get__(
        self, instance: "QObject", owner: typing.Type["QObject"]
    ) -> pyqtBoundSignal[typing.Unpack[Ts]]:
        ...

    def __get__(
        self, instance: typing.Optional["QObject"], owner: typing.Type["QObject"]
    ) -> typing.Union[
        "pyqtSignal[typing.Unpack[Ts]]", pyqtBoundSignal[typing.Unpack[Ts]]
    ]:
        # mypy-play doesn't seem to offer a 'stub' option
        pass


# Convenient type aliases.
PYQT_SLOT = typing.Union[
    typing.Callable[[typing.Unpack[Ts]], object], pyqtBoundSignal[typing.Unpack[Ts]]
]


class QObject:
    pass


class QMetaObject:
    class Connection:
        pass


class D(QObject):
    signal = pyqtSignal(int, str)


def accept_str_int(x: str, y: int) -> None:
    return None


def accept_str_str(x: str, y: int) -> None:
    return None


reveal_type(D.signal)
d = D()
reveal_type(d.signal)
d.signal.emit(1, "s")
d.signal.connect(accept_str_int)

# above should be good, below should be bad

d.signal.emit(1, 2)
d.signal.connect(accept_str_str)

@altendky
Copy link
Collaborator Author

I'm missing a typing.Type layer on pyqtSignal.__init__(). I'll have to look over the PEP to check on that tomorrow.

@altendky
Copy link
Collaborator Author

Well, unless I missed it there isn't any reference to applying typing.Type to typing.TypeVarTuples. It isn't great but they could still be used like my_signal: "pyqtSignal[int, str]" = pyqtSignal(int, str) maybe. I really should just dive into Mypy plugins one of these days...

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

No branches or pull requests

3 participants