Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
57 commits
Select commit Hold shift + click to select a range
7f4cbc3
mypy for both PySide2 and PyQt5
altendky Jul 8, 2020
5ba2a17
bool(... == ...) no longer needed
altendky Jul 8, 2020
e3bcb0f
Use PyQt5-stubs PR for now
altendky Jul 9, 2020
5788fa1
fixup ci
altendky Jul 9, 2020
da58981
fi
altendky Jul 9, 2020
7efeb4e
Add newsfragments/83.misc.rst
altendky Jul 9, 2020
4f65871
Merge branch 'master' into mypy_for_both
altendky Jul 9, 2020
93aa787
--upgrade
altendky Jul 9, 2020
f6fc46e
just do it
altendky Jul 9, 2020
1f502dd
-m it
altendky Jul 9, 2020
e67dbe7
# type: ignore
altendky Jul 9, 2020
932564a
Disable warn_unused_ignores for now
altendky Jul 9, 2020
d9d33bb
Merge branch 'master' into mypy_for_both
altendky Jul 12, 2020
69dc782
Merge branch 'master' into mypy_for_both
altendky Sep 12, 2020
d190ee4
bound signal in stubs merged but not released
altendky Sep 12, 2020
1f85bd7
more SignalInstance cleanup
altendky Sep 12, 2020
698477b
Merge branch 'master' into mypy_for_both
altendky Sep 12, 2020
e8565ef
where'd bool go?
altendky Sep 12, 2020
01ebded
ack!
altendky Sep 13, 2020
a7e5acc
more ignores...
altendky Sep 13, 2020
bfcb83e
Merge branch 'master' into mypy_for_both
altendky Oct 2, 2020
37793d9
pyqt5-stubs master
altendky Oct 2, 2020
ed5dad7
Merge branch 'master' into mypy_for_both
altendky Dec 31, 2020
dd0a113
only install qtpy and pyqt5-stubs from git if doing type checking
altendky Dec 31, 2020
1d969f8
Merge branch 'master' into mypy_for_both
altendky Dec 31, 2020
e449b2c
fixups
altendky Dec 31, 2020
eb98205
update readme example for mypy
altendky Dec 31, 2020
ca35b37
catchup
altendky Dec 31, 2020
05f56f2
nope, need qtpy for tests too
altendky Dec 31, 2020
39b1bf6
latest pyside but only for mypy
altendky Dec 31, 2020
0a3fac4
nitpick ignore
altendky Dec 31, 2020
e3932cf
black
altendky Dec 31, 2020
e17cba1
workarounds
altendky Dec 31, 2020
25e0040
workaround the workarounds
altendky Dec 31, 2020
de54fb3
bunch
altendky Dec 31, 2020
6d70fe7
**
altendky Dec 31, 2020
74b0a25
only install latest pyside for hint checks if checking pyside
altendky Jan 1, 2021
9035cfa
oops
altendky Jan 1, 2021
75e6b7b
more [ and ]
altendky Jan 1, 2021
e2a5478
fewer ignores
altendky Jan 1, 2021
fc3fc2c
hmm
altendky Jan 1, 2021
052d5a0
yay
altendky Jan 1, 2021
da36eaa
oops
altendky Jan 1, 2021
748fdba
black
altendky Jan 1, 2021
16eecdb
update nitpick ignores
altendky Jan 1, 2021
786fcae
update for qtpy rework
altendky Mar 22, 2021
4c63a08
Merge branch 'master' into mypy_for_both
altendky Mar 22, 2021
9e72b40
fix manifest check
altendky Mar 22, 2021
3e0cb9c
more fixup
altendky Mar 22, 2021
7af35d6
import typing
altendky Mar 22, 2021
f3e9be4
Merge branch 'main' into mypy_for_both
altendky Jul 9, 2021
485f579
black
altendky Jul 9, 2021
299ab63
Merge branch 'main' into mypy_for_both
altendky Jul 12, 2021
c9a7a4f
remove non-released installs
altendky Jul 12, 2021
fabcaa5
pyqt5-stubs ~=5.15.2
altendky Jul 12, 2021
80eb698
remove outdated comment
altendky Jul 12, 2021
7ababbc
fixup nitpick ignore
altendky Jul 12, 2021
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -60,15 +60,23 @@ jobs:
- python: '3.8'
check_docs: '1'
name: 'Check docs'
qt_library: 'PySide2'
- python: '3.8'
check_formatting: '1'
name: 'Check formatting'
qt_library: 'PySide2'
- python: '3.8'
check_type_hints: '1'
name: 'Check type hints'
name: 'Check type hints (PySide2)'
qt_library: 'PySide2'
- python: '3.8'
check_type_hints: '1'
name: 'Check type hints (PyQt5)'
qt_library: 'PyQt5'
- python: '3.8'
check_manifest: '1'
name: 'Check manifest'
qt_library: 'PySide2'
steps:
- name: Checkout
uses: actions/checkout@v2
Expand All @@ -87,7 +95,7 @@ jobs:
JOB_NAME: '${{ matrix.name }}'
# TODO: needs some Qt library but I'm not clear the implications of
# choosing one over the other
INSTALL_EXTRAS: '[pyside2,p_checks,p_docs]'
INSTALL_EXTRAS: '[${{ matrix.qt_library }},p_checks,p_docs]'

Windows:
name: 'Windows (${{ matrix.python }}, ${{ matrix.arch }}, ${{ matrix.qt_library }})'
Expand Down
11 changes: 7 additions & 4 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -79,13 +79,16 @@ kind of explodes when you shift into a classic Qt GUI setup.
class Main:
def __init__(
self,
application: QtWidgets.QApplication,
input_dialog: typing.Optional[QtWidgets.QInputDialog] = None,
output_dialog: typing.Optional[QtWidgets.QMessageBox] = None,
):
if input_dialog is None: # pragma: nocover
self.application = application

if input_dialog is None: # pragma: no cover
input_dialog = create_input()

if output_dialog is None: # pragma: nocover
if output_dialog is None: # pragma: no cover
output_dialog = create_output()

self.input_dialog = input_dialog
Expand All @@ -106,10 +109,10 @@ kind of explodes when you shift into a classic Qt GUI setup.
self.output_dialog.show()

def input_rejected(self) -> None:
QtCore.QCoreApplication.instance().quit()
self.application.quit()

def output_finished(self) -> None:
QtCore.QCoreApplication.instance().quit()
self.application.quit()

The third example, below, shows how using ``async`` and ``await`` allows us to
return to the more concise and clear description of the sequenced activity.
Expand Down
3 changes: 3 additions & 0 deletions ci.sh
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,9 @@ if [ "$CHECK_DOCS" = "1" ]; then
elif [ "$CHECK_FORMATTING" = "1" ]; then
source check.sh
elif [ "$CHECK_TYPE_HINTS" = "1" ]; then
if [[ "${INSTALL_EXTRAS,,}" == *"pyside2"* ]]; then
python -m pip install --upgrade pyside2
fi
mypy --package qtrio $(qts mypy args)
elif [ "$CHECK_MANIFEST" = "1" ]; then
check-manifest
Expand Down
4 changes: 4 additions & 0 deletions docs/source/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,10 @@
("py:class", "<class 'PySide2.QtWidgets.QMessageBox.Icon'>"),
# https://github.com/sphinx-doc/sphinx/issues/8136
("py:class", "typing.AbstractAsyncContextManager"),
(
"py:class",
"Union[<class 'PySide2.QtWidgets.QMessageBox.StandardButton'>, PySide2.QtWidgets.QMessageBox.StandardButtons]",
),
]

# -- General configuration ------------------------------------------------
Expand Down
3 changes: 3 additions & 0 deletions mypy.ini
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,6 @@ ignore_missing_imports = True
[mypy-qts.*]
;TODO: remove this and fixup the hints
follow_imports = skip

[mypy-trio.*]
ignore_missing_imports = True
1 change: 1 addition & 0 deletions newsfragments/83.misc.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
CI checks with mypy using both PySide2 and PyQt5.
69 changes: 58 additions & 11 deletions qtrio/_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,23 +4,20 @@
_reenter_event_type: The event type enumerator for our reenter events.
"""
import contextlib
import functools
import math
import sys
import traceback
import typing
import typing_extensions

import async_generator
import attr
import outcome

import qts
import trio
import trio.abc

import qtrio
import qtrio._qt
import qtrio._util


if typing.TYPE_CHECKING:
Expand Down Expand Up @@ -64,7 +61,17 @@ def register_event_type() -> None:
raise qtrio.EventTypeRegistrationFailedError()

# assign to the global
_reenter_event_type = QtCore.QEvent.Type(event_hint)
# TODO: https://bugreports.qt.io/browse/PYSIDE-1347
if qts.is_pyqt_5_wrapper:
_reenter_event_type = QtCore.QEvent.Type(event_hint)
elif qts.is_pyside_5_wrapper:
_reenter_event_type = typing.cast(
typing.Callable[[int], QtCore.QEvent.Type], QtCore.QEvent.Type
)(event_hint)
else: # pragma: no cover
raise qtrio.InternalError(
"You should not be here but you are running neither PyQt5 nor PySide2.",
)


def register_requested_event_type(
Expand All @@ -90,7 +97,18 @@ def register_requested_event_type(
if _reenter_event_type is not None:
raise qtrio.EventTypeAlreadyRegisteredError()

event_hint = QtCore.QEvent.registerEventType(requested_value)
# TODO: https://bugreports.qt.io/browse/PYSIDE-1468
if qts.is_pyqt_5_wrapper:
event_hint = QtCore.QEvent.registerEventType(requested_value)
elif qts.is_pyside_5_wrapper:
event_hint = typing.cast(
typing.Callable[[typing.Union[int, QtCore.QEvent.Type]], int],
QtCore.QEvent.registerEventType,
)(requested_value)
else: # pragma: no cover
raise qtrio.InternalError(
"You should not be here but you are running neither PyQt5 nor PySide2.",
)

if event_hint == -1:
raise qtrio.EventTypeRegistrationFailedError()
Expand All @@ -100,7 +118,17 @@ def register_requested_event_type(
)

# assign to the global
_reenter_event_type = QtCore.QEvent.Type(event_hint)
# TODO: https://bugreports.qt.io/browse/PYSIDE-1347
if qts.is_pyqt_5_wrapper:
_reenter_event_type = QtCore.QEvent.Type(event_hint)
elif qts.is_pyside_5_wrapper:
_reenter_event_type = typing.cast(
typing.Callable[[int], QtCore.QEvent.Type], QtCore.QEvent.Type
)(event_hint)
else: # pragma: no cover
raise qtrio.InternalError(
"You should not be here but you are running neither PyQt5 nor PySide2.",
)


async def wait_signal(signal: "QtCore.SignalInstance") -> typing.Tuple[object, ...]:
Expand Down Expand Up @@ -509,7 +537,21 @@ def maybe_build_application() -> "QtGui.QGuiApplication":
"""
from qts import QtWidgets # noqa: F811

maybe_application = QtWidgets.QApplication.instance()
application: QtCore.QCoreApplication

# TODO: https://bugreports.qt.io/browse/PYSIDE-1467
if qts.is_pyqt_5_wrapper:
maybe_application = QtWidgets.QApplication.instance()
elif qts.is_pyside_5_wrapper:
maybe_application = typing.cast(
typing.Optional["QtCore.QCoreApplication"],
QtWidgets.QApplication.instance(),
)
else: # pragma: no cover
raise qtrio.InternalError(
"You should not be here but you are running neither PyQt5 nor PySide2.",
)

if maybe_application is None:
application = QtWidgets.QApplication(sys.argv[1:])
else:
Expand Down Expand Up @@ -634,8 +676,8 @@ async def trio_main(
async_fn: typing.Callable[..., typing.Awaitable[object]],
args: typing.Tuple[object, ...],
) -> object:
"""Will be run as the main async function by the Trio guest. It creates a
cancellation scope to be cancelled when
"""Will be run as the main async function by the Trio guest. If it is a GUI
application then it creates a cancellation scope to be cancelled when
:meth:`QtGui.QGuiApplication.lastWindowClosed` is emitted. Within this scope
the application's ``async_fn`` will be run and passed ``args``.

Expand All @@ -647,11 +689,16 @@ async def trio_main(
Returns:
The result returned by `async_fn`.
"""
from qts import QtGui

result: object = None

with trio.CancelScope() as self.cancel_scope:
with contextlib.ExitStack() as exit_stack:
if self.application.quitOnLastWindowClosed():
if (
isinstance(self.application, QtGui.QGuiApplication)
and self.application.quitOnLastWindowClosed()
):
exit_stack.enter_context(
qtrio._qt.connection(
signal=self.application.lastWindowClosed,
Expand Down
34 changes: 23 additions & 11 deletions qtrio/_qt.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,16 @@
import contextlib
import typing

import typing_extensions

if typing.TYPE_CHECKING:
from qts import QtCore

import qtrio._python
import qtrio._util


class SignalProtocol(typing_extensions.Protocol):
signal: typing.ClassVar["QtCore.Signal"]


class Signal:
Expand All @@ -25,22 +30,23 @@ class Signal:

_attribute_name: typing.ClassVar[str] = ""

def __init__(self, *args: object, **kwargs: object) -> None:
def __init__(self, *types: type, name: typing.Optional[str] = None) -> None:
from qts import QtCore

class _SignalQObject(QtCore.QObject):
signal = QtCore.Signal(*args, **kwargs)
if name is None:
signal = QtCore.Signal(*types)
else:
signal = QtCore.Signal(*types, name=name)

self.object_cls = _SignalQObject
self.object_cls: typing.Type[SignalProtocol] = _SignalQObject

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

@typing.overload
def __get__(
self, instance: object, owner: object
) -> typing.Union["QtCore.SignalInstance"]:
def __get__(self, instance: object, owner: object) -> "QtCore.SignalInstance":
...

def __get__(
Expand All @@ -66,13 +72,15 @@ def object(self, instance: object) -> "QtCore.QObject":
Returns:
The signal-hosting :class:`QtCore.QObject`.
"""
d = getattr(instance, self._attribute_name, None)
d: typing.Optional[
typing.Dict[typing.Type[SignalProtocol], QtCore.QObject]
] = getattr(instance, self._attribute_name, None)

if d is None:
d = {}
setattr(instance, self._attribute_name, d)

o = d.get(self.object_cls)
o: typing.Optional[QtCore.QObject] = d.get(self.object_cls)
if o is None:
o = self.object_cls()
d[self.object_cls] = o
Expand All @@ -87,7 +95,11 @@ def object(self, instance: object) -> "QtCore.QObject":
def connection(
signal: "QtCore.SignalInstance", slot: typing.Callable[..., object]
) -> typing.Generator[
typing.Union["QtCore.QMetaObject.Connection", typing.Callable[..., object]],
typing.Union[
"QtCore.QMetaObject.Connection",
typing.Callable[..., object],
"QtCore.SignalInstance", # TODO: https://bugreports.qt.io/browse/PYSIDE-1334
],
None,
None,
]:
Expand Down
8 changes: 6 additions & 2 deletions qtrio/_tests/examples/readme/test_qt.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import qtrio.examples.readme.qt


def test_main(qtbot: pytestqt.qtbot.QtBot) -> None:
def test_main(qtbot: pytestqt.qtbot.QtBot, qapp: QtWidgets.QApplication) -> None:
input_dialog = qtrio.examples.readme.qt.create_input()
output_dialog = qtrio.examples.readme.qt.create_output()

Expand All @@ -14,6 +14,7 @@ def test_main(qtbot: pytestqt.qtbot.QtBot) -> None:
text_to_enter = "everyone"

main_object = qtrio.examples.readme.qt.Main(
application=qapp,
input_dialog=input_dialog,
output_dialog=output_dialog,
)
Expand All @@ -35,7 +36,9 @@ def test_main(qtbot: pytestqt.qtbot.QtBot) -> None:
assert text_to_enter in output_text


def test_main_cancelled(qtbot: pytestqt.qtbot.QtBot) -> None:
def test_main_cancelled(
qtbot: pytestqt.qtbot.QtBot, qapp: QtWidgets.QApplication
) -> None:
input_dialog = qtrio.examples.readme.qt.create_input()
output_dialog = qtrio.examples.readme.qt.create_output()

Expand All @@ -45,6 +48,7 @@ def test_main_cancelled(qtbot: pytestqt.qtbot.QtBot) -> None:
text_to_enter = "everyone"

main_object = qtrio.examples.readme.qt.Main(
application=qapp,
input_dialog=input_dialog,
output_dialog=output_dialog,
)
Expand Down
3 changes: 0 additions & 3 deletions qtrio/_tests/examples/test_emissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,6 @@ async def test_main(
results: typing.List[str] = []

for button in buttons:
# TODO: Doesn't work reliably on macOS in GitHub Actions. Seems to
# sometimes just miss the click entirely.
# qtbot.mouseClick(button, QtCore.Qt.LeftButton)
button.click()
await trio.testing.wait_all_tasks_blocked(cushion=0.01)
results.append(widget.label.text())
Expand Down
20 changes: 20 additions & 0 deletions qtrio/_tests/test_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,26 @@ def handler():
timeout = 40


def test_reenter_event_raises_if_type_not_registered(testdir):
test_file = r"""
import pytest

import qtrio
import qtrio.qt

def test():
with pytest.raises(
qtrio.InternalError,
match="reenter event type must be registered",
):
qtrio.qt.ReenterEvent(fn=lambda: None)
"""
testdir.makepyfile(test_file)

result = testdir.runpytest_subprocess(timeout=timeout)
result.assert_outcomes(passed=1)


def test_run_returns_value(testdir):
""":func:`qtrio.run()` returns the result of the passed async function."""

Expand Down
Loading