From 748f044618cf694b005eb3404d18d31f65240673 Mon Sep 17 00:00:00 2001 From: Kyle Altendorf Date: Thu, 18 Jun 2020 18:51:19 -0400 Subject: [PATCH 01/71] Add back dialogs --- qtrio/_dialogs.py | 87 ++++++++++++++++++++++++++++++++++++ qtrio/_tests/test_dialogs.py | 73 ++++++++++++++++++++++++++++++ 2 files changed, 160 insertions(+) create mode 100644 qtrio/_dialogs.py create mode 100644 qtrio/_tests/test_dialogs.py diff --git a/qtrio/_dialogs.py b/qtrio/_dialogs.py new file mode 100644 index 00000000..88d1de7b --- /dev/null +++ b/qtrio/_dialogs.py @@ -0,0 +1,87 @@ +import contextlib +import typing + +import attr +import PyQt5.QtCore +import PyQt5.QtWidgets + +import qtrio._qt + + +@attr.s(auto_attribs=True) +class IntegerDialog: + parent: PyQt5.QtWidgets.QWidget + dialog: typing.Optional[PyQt5.QtWidgets.QInputDialog] = None + edit_widget: typing.Optional[PyQt5.QtWidgets.QWidget] = None + ok_button: typing.Optional[PyQt5.QtWidgets.QPushButton] = None + cancel_button: typing.Optional[PyQt5.QtWidgets.QPushButton] = None + attempt: typing.Optional[int] = None + result: typing.Optional[int] = None + + shown = qtrio._qt.Signal(PyQt5.QtWidgets.QInputDialog) + hidden = qtrio._qt.Signal() + + @classmethod + def build(cls, parent: PyQt5.QtCore.QObject = None,) -> "IntegerDialog": + return cls(parent=parent) + + def setup(self): + self.dialog = PyQt5.QtWidgets.QInputDialog(self.parent) + + # TODO: find a better way to trigger population of widgets + self.dialog.show() + + for widget in self.dialog.findChildren(PyQt5.QtWidgets.QWidget): + if isinstance(widget, PyQt5.QtWidgets.QLineEdit): + self.edit_widget = widget + elif isinstance(widget, PyQt5.QtWidgets.QPushButton): + if widget.text() == self.dialog.okButtonText(): + self.ok_button = widget + elif widget.text() == self.dialog.cancelButtonText(): + self.cancel_button = widget + + widgets = {self.edit_widget, self.ok_button, self.cancel_button} + if None not in widgets: + break + else: + raise qtrio._qt.QTrioException("not all widgets found") + + if self.attempt is None: + self.attempt = 0 + else: + self.attempt += 1 + + self.shown.emit(self.dialog) + + def teardown(self): + self.edit_widget = None + self.ok_button = None + self.cancel_button = None + + if self.dialog is not None: + self.dialog.hide() + self.dialog = None + self.hidden.emit() + + @contextlib.contextmanager + def manager(self): + try: + self.setup() + yield + finally: + self.teardown() + + async def wait(self) -> int: + while True: + with self.manager(): + [result] = await qtrio._core.wait_signal(self.dialog.finished) + + if result == PyQt5.QtWidgets.QDialog.Rejected: + raise qtrio.UserCancelledError() + + try: + self.result = int(self.dialog.textValue()) + except ValueError: + continue + + return self.result diff --git a/qtrio/_tests/test_dialogs.py b/qtrio/_tests/test_dialogs.py new file mode 100644 index 00000000..f0d0db95 --- /dev/null +++ b/qtrio/_tests/test_dialogs.py @@ -0,0 +1,73 @@ +import PyQt5.QtCore +import pytest +import trio + + +import qtrio +import qtrio._core +import qtrio._dialogs + + +@qtrio.host +async def test_get_integer_gets_value(request, qtbot): + dialog = qtrio._dialogs.IntegerDialog.build() + dialog.shown.connect(qtbot.addWidget) + + async def user(task_status): + async with qtrio._core.signal_event_manager(dialog.shown): + task_status.started() + + qtbot.keyClicks(dialog.edit_widget, str(test_value)) + qtbot.mouseClick(dialog.ok_button, PyQt5.QtCore.Qt.LeftButton) + + test_value = 928 + + async with trio.open_nursery() as nursery: + await nursery.start(user) + integer = await dialog.wait() + + assert integer == test_value + + +@qtrio.host +async def test_get_integer_raises_cancel_when_canceled(request, qtbot): + dialog = qtrio._dialogs.IntegerDialog.build() + dialog.shown.connect(qtbot.addWidget) + + async def user(task_status): + async with qtrio._core.signal_event_manager(dialog.shown): + task_status.started() + + qtbot.keyClicks(dialog.edit_widget, "abc") + qtbot.mouseClick(dialog.cancel_button, PyQt5.QtCore.Qt.LeftButton) + + async with trio.open_nursery() as nursery: + await nursery.start(user) + with pytest.raises(qtrio.UserCancelledError): + await dialog.wait() + + +@qtrio.host +async def test_get_integer_gets_value_after_retry(request, qtbot): + dialog = qtrio._dialogs.IntegerDialog.build() + dialog.shown.connect(qtbot.addWidget) + + test_value = 928 + + async def user(task_status): + async with qtrio._core.signal_event_manager(dialog.shown): + task_status.started() + + qtbot.keyClicks(dialog.edit_widget, "abc") + + async with qtrio._core.signal_event_manager(dialog.shown): + qtbot.mouseClick(dialog.ok_button, PyQt5.QtCore.Qt.LeftButton) + + qtbot.keyClicks(dialog.edit_widget, str(test_value)) + qtbot.mouseClick(dialog.ok_button, PyQt5.QtCore.Qt.LeftButton) + + async with trio.open_nursery() as nursery: + await nursery.start(user) + integer = await dialog.wait() + + assert integer == test_value From 079000572affd1aae1bc59799be576174cc1982e Mon Sep 17 00:00:00 2001 From: Kyle Altendorf Date: Sun, 21 Jun 2020 22:01:35 -0400 Subject: [PATCH 02/71] catch up with PySide2 support --- qtrio/__init__.py | 1 + qtrio/_core.py | 9 +++++++++ qtrio/_dialogs.py | 28 ++++++++++++++-------------- qtrio/_tests/test_dialogs.py | 30 +++++++++++++++--------------- 4 files changed, 39 insertions(+), 29 deletions(-) diff --git a/qtrio/__init__.py b/qtrio/__init__.py index 7954bce8..d93bab28 100644 --- a/qtrio/__init__.py +++ b/qtrio/__init__.py @@ -12,6 +12,7 @@ from ._core import ( wait_signal, + wait_signal_context, Outcomes, run, Runner, diff --git a/qtrio/_core.py b/qtrio/_core.py index 86416934..443544f1 100644 --- a/qtrio/_core.py +++ b/qtrio/_core.py @@ -83,6 +83,15 @@ def slot(*args): return result +@async_generator.asynccontextmanager +async def wait_signal_context(signal: SignalInstance) -> None: + event = trio.Event() + + with qtrio._qt.connection(signal=signal, slot=lambda *args, **kwargs: event.set()): + yield + await event.wait() + + @attr.s(auto_attribs=True, frozen=True, slots=True) class Outcomes: """This class holds the :class:`outcomes.Outcome`s of both the Trio and the Qt diff --git a/qtrio/_dialogs.py b/qtrio/_dialogs.py index 88d1de7b..b6ae877d 100644 --- a/qtrio/_dialogs.py +++ b/qtrio/_dialogs.py @@ -2,39 +2,39 @@ import typing import attr -import PyQt5.QtCore -import PyQt5.QtWidgets +from qtpy import QtCore +from qtpy import QtWidgets import qtrio._qt @attr.s(auto_attribs=True) class IntegerDialog: - parent: PyQt5.QtWidgets.QWidget - dialog: typing.Optional[PyQt5.QtWidgets.QInputDialog] = None - edit_widget: typing.Optional[PyQt5.QtWidgets.QWidget] = None - ok_button: typing.Optional[PyQt5.QtWidgets.QPushButton] = None - cancel_button: typing.Optional[PyQt5.QtWidgets.QPushButton] = None + parent: QtWidgets.QWidget + dialog: typing.Optional[QtWidgets.QInputDialog] = None + edit_widget: typing.Optional[QtWidgets.QWidget] = None + ok_button: typing.Optional[QtWidgets.QPushButton] = None + cancel_button: typing.Optional[QtWidgets.QPushButton] = None attempt: typing.Optional[int] = None result: typing.Optional[int] = None - shown = qtrio._qt.Signal(PyQt5.QtWidgets.QInputDialog) + shown = qtrio._qt.Signal(QtWidgets.QInputDialog) hidden = qtrio._qt.Signal() @classmethod - def build(cls, parent: PyQt5.QtCore.QObject = None,) -> "IntegerDialog": + def build(cls, parent: QtCore.QObject = None,) -> "IntegerDialog": return cls(parent=parent) def setup(self): - self.dialog = PyQt5.QtWidgets.QInputDialog(self.parent) + self.dialog = QtWidgets.QInputDialog(self.parent) # TODO: find a better way to trigger population of widgets self.dialog.show() - for widget in self.dialog.findChildren(PyQt5.QtWidgets.QWidget): - if isinstance(widget, PyQt5.QtWidgets.QLineEdit): + for widget in self.dialog.findChildren(QtWidgets.QWidget): + if isinstance(widget, QtWidgets.QLineEdit): self.edit_widget = widget - elif isinstance(widget, PyQt5.QtWidgets.QPushButton): + elif isinstance(widget, QtWidgets.QPushButton): if widget.text() == self.dialog.okButtonText(): self.ok_button = widget elif widget.text() == self.dialog.cancelButtonText(): @@ -76,7 +76,7 @@ async def wait(self) -> int: with self.manager(): [result] = await qtrio._core.wait_signal(self.dialog.finished) - if result == PyQt5.QtWidgets.QDialog.Rejected: + if result == QtWidgets.QDialog.Rejected: raise qtrio.UserCancelledError() try: diff --git a/qtrio/_tests/test_dialogs.py b/qtrio/_tests/test_dialogs.py index f0d0db95..49b0595f 100644 --- a/qtrio/_tests/test_dialogs.py +++ b/qtrio/_tests/test_dialogs.py @@ -1,4 +1,4 @@ -import PyQt5.QtCore +from qtpy import QtCore import pytest import trio @@ -11,20 +11,20 @@ @qtrio.host async def test_get_integer_gets_value(request, qtbot): dialog = qtrio._dialogs.IntegerDialog.build() - dialog.shown.connect(qtbot.addWidget) async def user(task_status): - async with qtrio._core.signal_event_manager(dialog.shown): + async with qtrio.wait_signal_context(dialog.shown): task_status.started() qtbot.keyClicks(dialog.edit_widget, str(test_value)) - qtbot.mouseClick(dialog.ok_button, PyQt5.QtCore.Qt.LeftButton) + qtbot.mouseClick(dialog.ok_button, QtCore.Qt.LeftButton) test_value = 928 async with trio.open_nursery() as nursery: await nursery.start(user) - integer = await dialog.wait() + with qtrio._qt.connection(signal=dialog.shown, slot=qtbot.addWidget): + integer = await dialog.wait() assert integer == test_value @@ -32,42 +32,42 @@ async def user(task_status): @qtrio.host async def test_get_integer_raises_cancel_when_canceled(request, qtbot): dialog = qtrio._dialogs.IntegerDialog.build() - dialog.shown.connect(qtbot.addWidget) async def user(task_status): - async with qtrio._core.signal_event_manager(dialog.shown): + async with qtrio.wait_signal_context(dialog.shown): task_status.started() qtbot.keyClicks(dialog.edit_widget, "abc") - qtbot.mouseClick(dialog.cancel_button, PyQt5.QtCore.Qt.LeftButton) + qtbot.mouseClick(dialog.cancel_button, QtCore.Qt.LeftButton) async with trio.open_nursery() as nursery: await nursery.start(user) with pytest.raises(qtrio.UserCancelledError): - await dialog.wait() + with qtrio._qt.connection(signal=dialog.shown, slot=qtbot.addWidget): + await dialog.wait() @qtrio.host async def test_get_integer_gets_value_after_retry(request, qtbot): dialog = qtrio._dialogs.IntegerDialog.build() - dialog.shown.connect(qtbot.addWidget) test_value = 928 async def user(task_status): - async with qtrio._core.signal_event_manager(dialog.shown): + async with qtrio.wait_signal_context(dialog.shown): task_status.started() qtbot.keyClicks(dialog.edit_widget, "abc") - async with qtrio._core.signal_event_manager(dialog.shown): - qtbot.mouseClick(dialog.ok_button, PyQt5.QtCore.Qt.LeftButton) + async with qtrio.wait_signal_context(dialog.shown): + qtbot.mouseClick(dialog.ok_button, QtCore.Qt.LeftButton) qtbot.keyClicks(dialog.edit_widget, str(test_value)) - qtbot.mouseClick(dialog.ok_button, PyQt5.QtCore.Qt.LeftButton) + qtbot.mouseClick(dialog.ok_button, QtCore.Qt.LeftButton) async with trio.open_nursery() as nursery: await nursery.start(user) - integer = await dialog.wait() + with qtrio._qt.connection(signal=dialog.shown, slot=qtbot.addWidget): + integer = await dialog.wait() assert integer == test_value From d5c7980a83541fc899a8944006b9ef448a0f20df Mon Sep 17 00:00:00 2001 From: Kyle Altendorf Date: Mon, 22 Jun 2020 23:07:46 -0400 Subject: [PATCH 03/71] Add some dialogs --- qtrio/_dialogs.py | 232 +++++++++++++++++++++++++++++++++++ qtrio/_tests/test_dialogs.py | 71 +++++++++++ 2 files changed, 303 insertions(+) diff --git a/qtrio/_dialogs.py b/qtrio/_dialogs.py index b6ae877d..011edbc0 100644 --- a/qtrio/_dialogs.py +++ b/qtrio/_dialogs.py @@ -1,9 +1,12 @@ import contextlib +import os import typing +import async_generator import attr from qtpy import QtCore from qtpy import QtWidgets +import trio import qtrio._qt @@ -85,3 +88,232 @@ async def wait(self) -> int: continue return self.result + + +@attr.s(auto_attribs=True) +class TextInputDialog: + title: typing.Optional[str] = None + label: typing.Optional[str] = None + parent: typing.Optional[QtCore.QObject] = None + + dialog: typing.Optional[QtWidgets.QInputDialog] = None + accept_button: typing.Optional[QtWidgets.QPushButton] = None + reject_button: typing.Optional[QtWidgets.QPushButton] = None + line_edit: typing.Optional[QtWidgets.QLineEdit] = None + result: typing.Optional[trio.Path] = None + + shown = qtrio._qt.Signal(QtWidgets.QFileDialog) + + def setup(self): + self.result = None + + self.dialog = QtWidgets.QInputDialog(parent=self.parent) + if self.label is not None: + self.dialog.setLabelText(self.label) + if self.title is not None: + self.dialog.setWindowTitle(self.title) + + self.dialog.show() + + buttons = dialog_button_box_buttons_by_role(dialog=self.dialog) + self.accept_button = buttons[QtWidgets.QDialogButtonBox.AcceptRole] + self.reject_button = buttons[QtWidgets.QDialogButtonBox.RejectRole] + + [self.line_edit] = self.dialog.findChildren(QtWidgets.QLineEdit) + + self.shown.emit(self.dialog) + + def teardown(self): + if self.dialog is not None: + self.dialog.close() + self.dialog = None + self.accept_button = None + self.reject_button = None + + @contextlib.contextmanager + def manage(self): + try: + self.setup() + yield self + finally: + self.teardown() + + async def wait(self): + with self.manage(): + [result] = await qtrio._core.wait_signal(self.dialog.finished) + + if result == QtWidgets.QDialog.Rejected: + raise qtrio.UserCancelledError() + + self.result = self.dialog.textValue() + + return self.result + + +def create_text_input_dialog( + title: typing.Optional[str] = None, + label: typing.Optional[str] = None, + parent: typing.Optional[QtCore.QObject] = None, +): + return TextInputDialog(title=title, label=label, parent=parent) + + +def dialog_button_box_buttons_by_role( + dialog: QtWidgets.QDialog, +) -> typing.Mapping[QtWidgets.QDialogButtonBox.ButtonRole, QtWidgets.QAbstractButton]: + [button_box] = dialog.findChildren(QtWidgets.QDialogButtonBox) + + return {button_box.buttonRole(button): button for button in button_box.buttons()} + + +@attr.s(auto_attribs=True) +class FileDialog: + file_mode: QtWidgets.QFileDialog.FileMode + accept_mode: QtWidgets.QFileDialog.AcceptMode + dialog: typing.Optional[QtWidgets.QFileDialog] = None + parent: typing.Optional[QtCore.QObject] = None + default_path: typing.Optional[trio.Path] = None + options: QtWidgets.QFileDialog.Options = QtWidgets.QFileDialog.Options() + accept_button: typing.Optional[QtWidgets.QPushButton] = None + reject_button: typing.Optional[QtWidgets.QPushButton] = None + result: typing.Optional[trio.Path] = None + + shown = qtrio._qt.Signal(QtWidgets.QFileDialog) + + def setup(self): + self.result = None + + self.dialog = QtWidgets.QFileDialog(parent=self.parent) + self.dialog.setFileMode(self.file_mode) + self.dialog.setAcceptMode(self.accept_mode) + if self.default_path is not None: + self.dialog.selectFile(os.fspath(self.default_path)) + + self.dialog.show() + + buttons = dialog_button_box_buttons_by_role(dialog=self.dialog) + self.accept_button = buttons[QtWidgets.QDialogButtonBox.AcceptRole] + self.reject_button = buttons[QtWidgets.QDialogButtonBox.RejectRole] + + self.shown.emit(self.dialog) + + def teardown(self): + if self.dialog is not None: + self.dialog.close() + self.dialog = None + self.accept_button = None + self.reject_button = None + + @contextlib.contextmanager + def manage(self): + try: + self.setup() + yield self + finally: + self.teardown() + + async def wait(self): + with self.manage(): + [result] = await qtrio._core.wait_signal(self.dialog.finished) + + if result == QtWidgets.QDialog.Rejected: + raise qtrio.UserCancelledError() + + [path_string] = self.dialog.selectedFiles() + self.result = trio.Path(path_string) + + return self.result + + +def create_file_save_dialog( + parent: typing.Optional[QtCore.QObject] = None, + default_path: typing.Optional[trio.Path] = None, + options: QtWidgets.QFileDialog.Options = QtWidgets.QFileDialog.Options(), +): + return FileDialog( + parent=parent, + default_path=default_path, + options=options, + file_mode=QtWidgets.QFileDialog.AnyFile, + accept_mode=QtWidgets.QFileDialog.AcceptSave, + ) + + +@attr.s(auto_attribs=True) +class MessageBox: + icon: QtWidgets.QMessageBox.Icon + title: str + text: str + buttons: QtWidgets.QMessageBox.StandardButtons + + parent: typing.Optional[QtCore.QObject] = None + + dialog: typing.Optional[QtWidgets.QMessageBox] = None + accept_button: typing.Optional[QtWidgets.QPushButton] = None + result: typing.Optional[trio.Path] = None + + shown = qtrio._qt.Signal(QtWidgets.QMessageBox) + + def setup(self): + self.result = None + + self.dialog = QtWidgets.QMessageBox( + self.icon, self.title, self.text, self.buttons, self.parent + ) + + self.dialog.show() + + buttons = dialog_button_box_buttons_by_role(dialog=self.dialog) + self.accept_button = buttons[QtWidgets.QDialogButtonBox.AcceptRole] + + self.shown.emit(self.dialog) + + def teardown(self): + if self.dialog is not None: + self.dialog.close() + self.dialog = None + self.accept_button = None + + @contextlib.contextmanager + def manage(self): + try: + self.setup() + yield self + finally: + self.teardown() + + async def wait(self): + with self.manage(): + [result] = await qtrio._core.wait_signal(self.dialog.finished) + + if result == QtWidgets.QDialog.Rejected: + raise qtrio.UserCancelledError() + + +def create_information_message_box( + icon: QtWidgets.QMessageBox.Icon, + title: str, + text: str, + buttons: QtWidgets.QMessageBox.StandardButtons = QtWidgets.QMessageBox.Ok, + parent: typing.Optional[QtCore.QObject] = None, +): + return MessageBox(icon=icon, title=title, text=text, buttons=buttons, parent=parent) + + +@async_generator.asynccontextmanager +async def manage_progress_dialog( + title: str, + label: str, + minimum: int = 0, + maximum: int = 0, + cancel_button_text: str = "Cancel", + parent: QtCore.QObject = None, +): + dialog = QtWidgets.QProgressDialog( + label, cancel_button_text, minimum, maximum, parent + ) + try: + dialog.setWindowTitle(title) + yield dialog + finally: + dialog.close() diff --git a/qtrio/_tests/test_dialogs.py b/qtrio/_tests/test_dialogs.py index 49b0595f..8c9264c1 100644 --- a/qtrio/_tests/test_dialogs.py +++ b/qtrio/_tests/test_dialogs.py @@ -1,4 +1,7 @@ +import os + from qtpy import QtCore +from qtpy import QtWidgets import pytest import trio @@ -71,3 +74,71 @@ async def user(task_status): integer = await dialog.wait() assert integer == test_value + + +@qtrio.host +async def test_file_save(request, qtbot, tmp_path): + dialog = qtrio._dialogs.create_file_save_dialog() + + path_to_select = tmp_path / "something.new" + + async def user(task_status): + async with qtrio.wait_signal_context(dialog.shown): + task_status.started() + + dialog.dialog.selectFile(os.fspath(path_to_select)) + dialog.dialog.accept() + + async with trio.open_nursery() as nursery: + await nursery.start(user) + with qtrio._qt.connection(signal=dialog.shown, slot=qtbot.addWidget): + selected_path = await dialog.wait() + + assert selected_path == path_to_select + + +@qtrio.host +async def test_information_message_box(request, qtbot): + text = "Consider yourself informed." + queried_text = None + + dialog = qtrio._dialogs.create_information_message_box( + icon=QtWidgets.QMessageBox.Information, title="Information", text=text, + ) + + async def user(task_status): + nonlocal queried_text + + async with qtrio.wait_signal_context(dialog.shown): + task_status.started() + + queried_text = dialog.dialog.text() + dialog.dialog.accept() + + async with trio.open_nursery() as nursery: + await nursery.start(user) + with qtrio._qt.connection(signal=dialog.shown, slot=qtbot.addWidget): + await dialog.wait() + + assert queried_text == text + + +@qtrio.host +async def test_text_input_dialog(request, qtbot): + dialog = qtrio._dialogs.create_text_input_dialog() + + entered_text = "etcetera" + + async def user(task_status): + async with qtrio.wait_signal_context(dialog.shown): + task_status.started() + + qtbot.keyClicks(dialog.line_edit, entered_text) + dialog.dialog.accept() + + async with trio.open_nursery() as nursery: + await nursery.start(user) + with qtrio._qt.connection(signal=dialog.shown, slot=qtbot.addWidget): + returned_text = await dialog.wait() + + assert returned_text == entered_text From 4160bae02d882bd2ec19ff4025231a6a51fbc153 Mon Sep 17 00:00:00 2001 From: Kyle Altendorf Date: Mon, 22 Jun 2020 23:14:41 -0400 Subject: [PATCH 04/71] allow FileDialog to not find buttons --- qtrio/_dialogs.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/qtrio/_dialogs.py b/qtrio/_dialogs.py index 011edbc0..8c93099c 100644 --- a/qtrio/_dialogs.py +++ b/qtrio/_dialogs.py @@ -161,8 +161,12 @@ def create_text_input_dialog( def dialog_button_box_buttons_by_role( dialog: QtWidgets.QDialog, ) -> typing.Mapping[QtWidgets.QDialogButtonBox.ButtonRole, QtWidgets.QAbstractButton]: - [button_box] = dialog.findChildren(QtWidgets.QDialogButtonBox) + hits = dialog.findChildren(QtWidgets.QDialogButtonBox) + if len(hits) == 0: + return {} + + [button_box] = hits return {button_box.buttonRole(button): button for button in button_box.buttons()} @@ -192,8 +196,8 @@ def setup(self): self.dialog.show() buttons = dialog_button_box_buttons_by_role(dialog=self.dialog) - self.accept_button = buttons[QtWidgets.QDialogButtonBox.AcceptRole] - self.reject_button = buttons[QtWidgets.QDialogButtonBox.RejectRole] + self.accept_button = buttons.get(QtWidgets.QDialogButtonBox.AcceptRole) + self.reject_button = buttons.get(QtWidgets.QDialogButtonBox.RejectRole) self.shown.emit(self.dialog) From 1e10b89130c5e1ceb9c004aa1be1abb51b4604ec Mon Sep 17 00:00:00 2001 From: Kyle Altendorf Date: Mon, 22 Jun 2020 23:31:42 -0400 Subject: [PATCH 05/71] select the directory before the file, maybe --- qtrio/_tests/test_dialogs.py | 1 + 1 file changed, 1 insertion(+) diff --git a/qtrio/_tests/test_dialogs.py b/qtrio/_tests/test_dialogs.py index 8c9264c1..19cccdce 100644 --- a/qtrio/_tests/test_dialogs.py +++ b/qtrio/_tests/test_dialogs.py @@ -86,6 +86,7 @@ async def user(task_status): async with qtrio.wait_signal_context(dialog.shown): task_status.started() + dialog.dialog.setDirectory(os.fspath(path_to_select.parent)) dialog.dialog.selectFile(os.fspath(path_to_select)) dialog.dialog.accept() From 29c2701907dad64353d32efda3e8f8a660e6b35d Mon Sep 17 00:00:00 2001 From: Kyle Altendorf Date: Tue, 23 Jun 2020 08:55:32 -0400 Subject: [PATCH 06/71] set the default path ahead for file dialog test --- qtrio/_tests/test_dialogs.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/qtrio/_tests/test_dialogs.py b/qtrio/_tests/test_dialogs.py index 19cccdce..9f50912d 100644 --- a/qtrio/_tests/test_dialogs.py +++ b/qtrio/_tests/test_dialogs.py @@ -78,16 +78,14 @@ async def user(task_status): @qtrio.host async def test_file_save(request, qtbot, tmp_path): - dialog = qtrio._dialogs.create_file_save_dialog() - path_to_select = tmp_path / "something.new" + dialog = qtrio._dialogs.create_file_save_dialog(default_path=path_to_select) + async def user(task_status): async with qtrio.wait_signal_context(dialog.shown): task_status.started() - dialog.dialog.setDirectory(os.fspath(path_to_select.parent)) - dialog.dialog.selectFile(os.fspath(path_to_select)) dialog.dialog.accept() async with trio.open_nursery() as nursery: From 7c4ab041fe306130a8998b49f4c8814c657e4434 Mon Sep 17 00:00:00 2001 From: Kyle Altendorf Date: Tue, 23 Jun 2020 09:12:10 -0400 Subject: [PATCH 07/71] actually do the last thing --- qtrio/_dialogs.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/qtrio/_dialogs.py b/qtrio/_dialogs.py index 8c93099c..a2d9bd19 100644 --- a/qtrio/_dialogs.py +++ b/qtrio/_dialogs.py @@ -187,11 +187,11 @@ class FileDialog: def setup(self): self.result = None - self.dialog = QtWidgets.QFileDialog(parent=self.parent) + self.dialog = QtWidgets.QFileDialog( + parent=self.parent, directory=os.fspath(self.default_path) + ) self.dialog.setFileMode(self.file_mode) self.dialog.setAcceptMode(self.accept_mode) - if self.default_path is not None: - self.dialog.selectFile(os.fspath(self.default_path)) self.dialog.show() From 178ffa6c8ce21e51cef9a7e0e835c3e1f596d30c Mon Sep 17 00:00:00 2001 From: Kyle Altendorf Date: Tue, 23 Jun 2020 09:14:08 -0400 Subject: [PATCH 08/71] trio.Path() for test_file_save() --- qtrio/_tests/test_dialogs.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/qtrio/_tests/test_dialogs.py b/qtrio/_tests/test_dialogs.py index 9f50912d..41c709bd 100644 --- a/qtrio/_tests/test_dialogs.py +++ b/qtrio/_tests/test_dialogs.py @@ -78,7 +78,7 @@ async def user(task_status): @qtrio.host async def test_file_save(request, qtbot, tmp_path): - path_to_select = tmp_path / "something.new" + path_to_select = trio.Path(tmp_path) / "something.new" dialog = qtrio._dialogs.create_file_save_dialog(default_path=path_to_select) From fb5e37764b90a28e8673c7a617353785324e334e Mon Sep 17 00:00:00 2001 From: Kyle Altendorf Date: Tue, 23 Jun 2020 09:43:06 -0400 Subject: [PATCH 09/71] maybe avoid a race and... --- qtrio/_dialogs.py | 33 +++++++++++++++++++++++---------- 1 file changed, 23 insertions(+), 10 deletions(-) diff --git a/qtrio/_dialogs.py b/qtrio/_dialogs.py index a2d9bd19..6135e3ab 100644 --- a/qtrio/_dialogs.py +++ b/qtrio/_dialogs.py @@ -183,6 +183,7 @@ class FileDialog: result: typing.Optional[trio.Path] = None shown = qtrio._qt.Signal(QtWidgets.QFileDialog) + finished = qtrio._qt.Signal(int) # QtWidgets.QDialog.DialogCode def setup(self): self.result = None @@ -193,6 +194,9 @@ def setup(self): self.dialog.setFileMode(self.file_mode) self.dialog.setAcceptMode(self.accept_mode) + # TODO: adjust so we can use a context manager? + self.dialog.finished.connect(self.finished) + self.dialog.show() buttons = dialog_button_box_buttons_by_role(dialog=self.dialog) @@ -204,23 +208,32 @@ def setup(self): def teardown(self): if self.dialog is not None: self.dialog.close() + self.dialog.finished.disconnect(self.finished) self.dialog = None self.accept_button = None self.reject_button = None @contextlib.contextmanager - def manage(self): - try: - self.setup() - yield self - finally: - self.teardown() + def manage(self, finished_event=None): + with contextlib.ExitStack() as exit_stack: + if finished_event is not None: + exit_stack.enter_context( + qtrio._qt.connection( + signal=self.finished, + slot=lambda *args, **kwargs: finished_event.set(), + ) + ) + try: + self.setup() + yield self + finally: + self.teardown() async def wait(self): - with self.manage(): - [result] = await qtrio._core.wait_signal(self.dialog.finished) - - if result == QtWidgets.QDialog.Rejected: + finished_event = trio.Event() + with self.manage(finished_event=finished_event): + await finished_event.wait() + if self.dialog.result() != QtWidgets.QDialog.Accepted: raise qtrio.UserCancelledError() [path_string] = self.dialog.selectedFiles() From 9b4f7a16b16cd093bba9015dd2fbc672e6b25964 Mon Sep 17 00:00:00 2001 From: Kyle Altendorf Date: Tue, 23 Jun 2020 09:56:36 -0400 Subject: [PATCH 10/71] Correct text input dialog shown signal parameter type --- qtrio/_dialogs.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/qtrio/_dialogs.py b/qtrio/_dialogs.py index 6135e3ab..ebb489a9 100644 --- a/qtrio/_dialogs.py +++ b/qtrio/_dialogs.py @@ -102,7 +102,7 @@ class TextInputDialog: line_edit: typing.Optional[QtWidgets.QLineEdit] = None result: typing.Optional[trio.Path] = None - shown = qtrio._qt.Signal(QtWidgets.QFileDialog) + shown = qtrio._qt.Signal(QtWidgets.QInputDialog) def setup(self): self.result = None From 1076979633d564a039804c071595b02d7f7066a8 Mon Sep 17 00:00:00 2001 From: Kyle Altendorf Date: Tue, 23 Jun 2020 10:15:56 -0400 Subject: [PATCH 11/71] Rework IntegerDialog to maybe avoid a race --- qtrio/_dialogs.py | 69 ++++++++++++++++++++++++----------------------- 1 file changed, 36 insertions(+), 33 deletions(-) diff --git a/qtrio/_dialogs.py b/qtrio/_dialogs.py index ebb489a9..75c4ca71 100644 --- a/qtrio/_dialogs.py +++ b/qtrio/_dialogs.py @@ -23,31 +23,27 @@ class IntegerDialog: shown = qtrio._qt.Signal(QtWidgets.QInputDialog) hidden = qtrio._qt.Signal() + finished = qtrio._qt.Signal(int) # QtWidgets.QDialog.DialogCode @classmethod def build(cls, parent: QtCore.QObject = None,) -> "IntegerDialog": return cls(parent=parent) def setup(self): + self.result = None + self.dialog = QtWidgets.QInputDialog(self.parent) - # TODO: find a better way to trigger population of widgets + # TODO: adjust so we can use a context manager? + self.dialog.finished.connect(self.finished) + self.dialog.show() - for widget in self.dialog.findChildren(QtWidgets.QWidget): - if isinstance(widget, QtWidgets.QLineEdit): - self.edit_widget = widget - elif isinstance(widget, QtWidgets.QPushButton): - if widget.text() == self.dialog.okButtonText(): - self.ok_button = widget - elif widget.text() == self.dialog.cancelButtonText(): - self.cancel_button = widget - - widgets = {self.edit_widget, self.ok_button, self.cancel_button} - if None not in widgets: - break - else: - raise qtrio._qt.QTrioException("not all widgets found") + buttons = dialog_button_box_buttons_by_role(dialog=self.dialog) + self.ok_button = buttons.get(QtWidgets.QDialogButtonBox.AcceptRole) + self.cancel_button = buttons.get(QtWidgets.QDialogButtonBox.RejectRole) + + [self.edit_widget] = self.dialog.findChildren(QtWidgets.QLineEdit) if self.attempt is None: self.attempt = 0 @@ -57,29 +53,36 @@ def setup(self): self.shown.emit(self.dialog) def teardown(self): - self.edit_widget = None + if self.dialog is not None: + self.dialog.close() + self.dialog.finished.disconnect(self.finished) + self.dialog = None self.ok_button = None self.cancel_button = None - - if self.dialog is not None: - self.dialog.hide() - self.dialog = None - self.hidden.emit() + self.edit_widget = None @contextlib.contextmanager - def manager(self): - try: - self.setup() - yield - finally: - self.teardown() + def manage(self, finished_event=None): + with contextlib.ExitStack() as exit_stack: + if finished_event is not None: + exit_stack.enter_context( + qtrio._qt.connection( + signal=self.finished, + slot=lambda *args, **kwargs: finished_event.set(), + ) + ) + try: + self.setup() + yield self + finally: + self.teardown() - async def wait(self) -> int: + async def wait(self): while True: - with self.manager(): - [result] = await qtrio._core.wait_signal(self.dialog.finished) - - if result == QtWidgets.QDialog.Rejected: + finished_event = trio.Event() + with self.manage(finished_event=finished_event): + await finished_event.wait() + if self.dialog.result() != QtWidgets.QDialog.Accepted: raise qtrio.UserCancelledError() try: @@ -87,7 +90,7 @@ async def wait(self) -> int: except ValueError: continue - return self.result + return self.result @attr.s(auto_attribs=True) From b28f0942abd39919d6a22721a5a43484d776fca3 Mon Sep 17 00:00:00 2001 From: Kyle Altendorf Date: Tue, 23 Jun 2020 12:14:12 -0400 Subject: [PATCH 12/71] Separate default directory and file --- qtrio/_dialogs.py | 22 ++++++++++++++++------ qtrio/_tests/test_dialogs.py | 4 +++- 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/qtrio/_dialogs.py b/qtrio/_dialogs.py index 75c4ca71..ed46c089 100644 --- a/qtrio/_dialogs.py +++ b/qtrio/_dialogs.py @@ -179,7 +179,8 @@ class FileDialog: accept_mode: QtWidgets.QFileDialog.AcceptMode dialog: typing.Optional[QtWidgets.QFileDialog] = None parent: typing.Optional[QtCore.QObject] = None - default_path: typing.Optional[trio.Path] = None + default_directory: typing.Optional[trio.Path] = None + default_file: typing.Optional[trio.Path] = None options: QtWidgets.QFileDialog.Options = QtWidgets.QFileDialog.Options() accept_button: typing.Optional[QtWidgets.QPushButton] = None reject_button: typing.Optional[QtWidgets.QPushButton] = None @@ -191,9 +192,16 @@ class FileDialog: def setup(self): self.result = None - self.dialog = QtWidgets.QFileDialog( - parent=self.parent, directory=os.fspath(self.default_path) - ) + extras = {} + + if self.default_directory is not None: + extras["directory"] = os.fspath(self.default_directory) + + self.dialog = QtWidgets.QFileDialog(parent=self.parent, **extras) + + if self.default_file is not None: + self.dialog.selectFile(os.fspath(self.default_file)) + self.dialog.setFileMode(self.file_mode) self.dialog.setAcceptMode(self.accept_mode) @@ -247,12 +255,14 @@ async def wait(self): def create_file_save_dialog( parent: typing.Optional[QtCore.QObject] = None, - default_path: typing.Optional[trio.Path] = None, + default_directory: typing.Optional[trio.Path] = None, + default_file: typing.Optional[trio.Path] = None, options: QtWidgets.QFileDialog.Options = QtWidgets.QFileDialog.Options(), ): return FileDialog( parent=parent, - default_path=default_path, + default_directory=default_directory, + default_file=default_file, options=options, file_mode=QtWidgets.QFileDialog.AnyFile, accept_mode=QtWidgets.QFileDialog.AcceptSave, diff --git a/qtrio/_tests/test_dialogs.py b/qtrio/_tests/test_dialogs.py index 41c709bd..532c9aa4 100644 --- a/qtrio/_tests/test_dialogs.py +++ b/qtrio/_tests/test_dialogs.py @@ -80,7 +80,9 @@ async def user(task_status): async def test_file_save(request, qtbot, tmp_path): path_to_select = trio.Path(tmp_path) / "something.new" - dialog = qtrio._dialogs.create_file_save_dialog(default_path=path_to_select) + dialog = qtrio._dialogs.create_file_save_dialog( + default_directory=path_to_select.parent, default_file=path_to_select + ) async def user(task_status): async with qtrio.wait_signal_context(dialog.shown): From 4c7c1ebb07792094a2b9b0dcc4bd5155cebaec81 Mon Sep 17 00:00:00 2001 From: Kyle Altendorf Date: Tue, 23 Jun 2020 18:42:52 -0400 Subject: [PATCH 13/71] non-native file dialog on macOS https://github.com/altendky/qtrio/issues/28 --- qtrio/_dialogs.py | 8 +++++++- qtrio/_tests/test_dialogs.py | 5 +++-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/qtrio/_dialogs.py b/qtrio/_dialogs.py index ed46c089..d670b798 100644 --- a/qtrio/_dialogs.py +++ b/qtrio/_dialogs.py @@ -1,5 +1,6 @@ import contextlib import os +import sys import typing import async_generator @@ -181,7 +182,12 @@ class FileDialog: parent: typing.Optional[QtCore.QObject] = None default_directory: typing.Optional[trio.Path] = None default_file: typing.Optional[trio.Path] = None - options: QtWidgets.QFileDialog.Options = QtWidgets.QFileDialog.Options() + # https://github.com/altendky/qtrio/issues/28 + options: QtWidgets.QFileDialog.Options = QtWidgets.QFileDialog.Options( + QtWidgets.QFileDialog.DontUseNativeDialog + if sys.platform == "darwin" + else QtWidgets.QFileDialog.Options() + ) accept_button: typing.Optional[QtWidgets.QPushButton] = None reject_button: typing.Optional[QtWidgets.QPushButton] = None result: typing.Optional[trio.Path] = None diff --git a/qtrio/_tests/test_dialogs.py b/qtrio/_tests/test_dialogs.py index 532c9aa4..537d9648 100644 --- a/qtrio/_tests/test_dialogs.py +++ b/qtrio/_tests/test_dialogs.py @@ -1,4 +1,4 @@ -import os +import sys from qtpy import QtCore from qtpy import QtWidgets @@ -78,10 +78,11 @@ async def user(task_status): @qtrio.host async def test_file_save(request, qtbot, tmp_path): + assert tmp_path.is_dir() path_to_select = trio.Path(tmp_path) / "something.new" dialog = qtrio._dialogs.create_file_save_dialog( - default_directory=path_to_select.parent, default_file=path_to_select + default_directory=path_to_select.parent, default_file=path_to_select, ) async def user(task_status): From b3e3b306a0586c8092b6fce116c70adbb1170663 Mon Sep 17 00:00:00 2001 From: Kyle Altendorf Date: Tue, 23 Jun 2020 18:50:54 -0400 Subject: [PATCH 14/71] hmm --- qtrio/_dialogs.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/qtrio/_dialogs.py b/qtrio/_dialogs.py index d670b798..226c0ee6 100644 --- a/qtrio/_dialogs.py +++ b/qtrio/_dialogs.py @@ -183,7 +183,7 @@ class FileDialog: default_directory: typing.Optional[trio.Path] = None default_file: typing.Optional[trio.Path] = None # https://github.com/altendky/qtrio/issues/28 - options: QtWidgets.QFileDialog.Options = QtWidgets.QFileDialog.Options( + options: QtWidgets.QFileDialog.Options = ( QtWidgets.QFileDialog.DontUseNativeDialog if sys.platform == "darwin" else QtWidgets.QFileDialog.Options() From fce79ace3c8e7d74f499e7f83f3673469b7a3f64 Mon Sep 17 00:00:00 2001 From: Kyle Altendorf Date: Tue, 23 Jun 2020 18:56:52 -0400 Subject: [PATCH 15/71] again --- qtrio/_dialogs.py | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/qtrio/_dialogs.py b/qtrio/_dialogs.py index 226c0ee6..c46e97aa 100644 --- a/qtrio/_dialogs.py +++ b/qtrio/_dialogs.py @@ -182,12 +182,7 @@ class FileDialog: parent: typing.Optional[QtCore.QObject] = None default_directory: typing.Optional[trio.Path] = None default_file: typing.Optional[trio.Path] = None - # https://github.com/altendky/qtrio/issues/28 - options: QtWidgets.QFileDialog.Options = ( - QtWidgets.QFileDialog.DontUseNativeDialog - if sys.platform == "darwin" - else QtWidgets.QFileDialog.Options() - ) + options: QtWidgets.QFileDialog.Options = QtWidgets.QFileDialog.Options() accept_button: typing.Optional[QtWidgets.QPushButton] = None reject_button: typing.Optional[QtWidgets.QPushButton] = None result: typing.Optional[trio.Path] = None @@ -203,7 +198,14 @@ def setup(self): if self.default_directory is not None: extras["directory"] = os.fspath(self.default_directory) - self.dialog = QtWidgets.QFileDialog(parent=self.parent, **extras) + options = self.options + if sys.platform == "darwin": + # https://github.com/altendky/qtrio/issues/28 + options |= QtWidgets.QFileDialog.Options() + + self.dialog = QtWidgets.QFileDialog( + parent=self.parent, options=options, **extras + ) if self.default_file is not None: self.dialog.selectFile(os.fspath(self.default_file)) From bac2d5e03c505e73ac957b27817e4435494115f1 Mon Sep 17 00:00:00 2001 From: Kyle Altendorf Date: Tue, 23 Jun 2020 19:04:25 -0400 Subject: [PATCH 16/71] it's DontUseNativeDialog --- qtrio/_dialogs.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/qtrio/_dialogs.py b/qtrio/_dialogs.py index c46e97aa..6c8aa532 100644 --- a/qtrio/_dialogs.py +++ b/qtrio/_dialogs.py @@ -201,7 +201,7 @@ def setup(self): options = self.options if sys.platform == "darwin": # https://github.com/altendky/qtrio/issues/28 - options |= QtWidgets.QFileDialog.Options() + options |= QtWidgets.QFileDialog.DontUseNativeDialog self.dialog = QtWidgets.QFileDialog( parent=self.parent, options=options, **extras From 8aad20791fa2347db1ef52b7618ec053014c9649 Mon Sep 17 00:00:00 2001 From: Kyle Altendorf Date: Tue, 23 Jun 2020 20:31:08 -0400 Subject: [PATCH 17/71] rework test_wait_signal_waits It seemed to be oddly failing in macOS --- qtrio/_tests/test_core.py | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/qtrio/_tests/test_core.py b/qtrio/_tests/test_core.py index ff514e53..7358519f 100644 --- a/qtrio/_tests/test_core.py +++ b/qtrio/_tests/test_core.py @@ -328,26 +328,37 @@ async def test(request): def test_wait_signal_returns_the_value(testdir): """wait_signal() waits for the signal.""" test_file = r""" + import time + from qtpy import QtCore import qtrio - import trio class MyQObject(QtCore.QObject): signal = QtCore.Signal(int) - async def emit(signal, value): - signal.emit(value) - @qtrio.host async def test(request): instance = MyQObject() - async with trio.open_nursery() as nursery: - nursery.start_soon(emit, instance.signal, 17) + def emit(): + instance.signal.emit(17) + + timer = QtCore.QTimer() + timer.setSingleShot(True) + timer.timeout.connect(emit) + + try: + start = time.monotonic() + timer.start(100) + result = await qtrio.wait_signal(instance.signal) + end = time.monotonic() + finally: + timer.timeout.disconnect(emit) + assert end - start > 0.090 assert result == (17,) """ testdir.makepyfile(test_file) From f1d8b3adb20de144177a3b13104c06fef8bdf756 Mon Sep 17 00:00:00 2001 From: Kyle Altendorf Date: Tue, 14 Jul 2020 18:10:28 -0400 Subject: [PATCH 18/71] Undo unrelated test changes --- qtrio/_tests/test_core.py | 23 ++++++----------------- 1 file changed, 6 insertions(+), 17 deletions(-) diff --git a/qtrio/_tests/test_core.py b/qtrio/_tests/test_core.py index 7358519f..ff514e53 100644 --- a/qtrio/_tests/test_core.py +++ b/qtrio/_tests/test_core.py @@ -328,37 +328,26 @@ async def test(request): def test_wait_signal_returns_the_value(testdir): """wait_signal() waits for the signal.""" test_file = r""" - import time - from qtpy import QtCore import qtrio + import trio class MyQObject(QtCore.QObject): signal = QtCore.Signal(int) + async def emit(signal, value): + signal.emit(value) + @qtrio.host async def test(request): instance = MyQObject() - def emit(): - instance.signal.emit(17) - - timer = QtCore.QTimer() - timer.setSingleShot(True) - timer.timeout.connect(emit) - - try: - start = time.monotonic() - timer.start(100) - + async with trio.open_nursery() as nursery: + nursery.start_soon(emit, instance.signal, 17) result = await qtrio.wait_signal(instance.signal) - end = time.monotonic() - finally: - timer.timeout.disconnect(emit) - assert end - start > 0.090 assert result == (17,) """ testdir.makepyfile(test_file) From e96b61db387e485d169700430b44c3e6fb1d742b Mon Sep 17 00:00:00 2001 From: Kyle Altendorf Date: Tue, 14 Jul 2020 22:50:32 -0400 Subject: [PATCH 19/71] catchup --- qtrio/_dialogs.py | 20 ++++++++++---------- qtrio/_exceptions.py | 6 ------ qtrio/_tests/test_dialogs.py | 22 ++++++++++++---------- 3 files changed, 22 insertions(+), 26 deletions(-) diff --git a/qtrio/_dialogs.py b/qtrio/_dialogs.py index 6c8aa532..17c1a1b0 100644 --- a/qtrio/_dialogs.py +++ b/qtrio/_dialogs.py @@ -84,12 +84,12 @@ async def wait(self): with self.manage(finished_event=finished_event): await finished_event.wait() if self.dialog.result() != QtWidgets.QDialog.Accepted: - raise qtrio.UserCancelledError() - - try: - self.result = int(self.dialog.textValue()) - except ValueError: - continue + self.result = None + else: + try: + self.result = int(self.dialog.textValue()) + except ValueError: + continue return self.result @@ -253,10 +253,10 @@ async def wait(self): with self.manage(finished_event=finished_event): await finished_event.wait() if self.dialog.result() != QtWidgets.QDialog.Accepted: - raise qtrio.UserCancelledError() - - [path_string] = self.dialog.selectedFiles() - self.result = trio.Path(path_string) + self.result = None + else: + [path_string] = self.dialog.selectedFiles() + self.result = trio.Path(path_string) return self.result diff --git a/qtrio/_exceptions.py b/qtrio/_exceptions.py index 8c5992a3..bc7bc043 100644 --- a/qtrio/_exceptions.py +++ b/qtrio/_exceptions.py @@ -27,12 +27,6 @@ def __eq__(self, other): return type(self) == type(other) and self.args == other.args -class UserCancelledError(QTrioException): - """Raised when a user requested cancellation of an operation.""" - - pass - - class RunnerTimedOutError(QTrioException): """Raised when a :class:`qtrio.Runner` times out the run.""" diff --git a/qtrio/_tests/test_dialogs.py b/qtrio/_tests/test_dialogs.py index 537d9648..0b0603a0 100644 --- a/qtrio/_tests/test_dialogs.py +++ b/qtrio/_tests/test_dialogs.py @@ -9,6 +9,7 @@ import qtrio import qtrio._core import qtrio._dialogs +import qtrio._qt @qtrio.host @@ -16,7 +17,7 @@ async def test_get_integer_gets_value(request, qtbot): dialog = qtrio._dialogs.IntegerDialog.build() async def user(task_status): - async with qtrio.wait_signal_context(dialog.shown): + async with qtrio._core.wait_signal_context(dialog.shown): task_status.started() qtbot.keyClicks(dialog.edit_widget, str(test_value)) @@ -37,7 +38,7 @@ async def test_get_integer_raises_cancel_when_canceled(request, qtbot): dialog = qtrio._dialogs.IntegerDialog.build() async def user(task_status): - async with qtrio.wait_signal_context(dialog.shown): + async with qtrio._core.wait_signal_context(dialog.shown): task_status.started() qtbot.keyClicks(dialog.edit_widget, "abc") @@ -45,9 +46,10 @@ async def user(task_status): async with trio.open_nursery() as nursery: await nursery.start(user) - with pytest.raises(qtrio.UserCancelledError): - with qtrio._qt.connection(signal=dialog.shown, slot=qtbot.addWidget): - await dialog.wait() + with qtrio._qt.connection(signal=dialog.shown, slot=qtbot.addWidget): + result = await dialog.wait() + + assert result is None @qtrio.host @@ -57,12 +59,12 @@ async def test_get_integer_gets_value_after_retry(request, qtbot): test_value = 928 async def user(task_status): - async with qtrio.wait_signal_context(dialog.shown): + async with qtrio._core.wait_signal_context(dialog.shown): task_status.started() qtbot.keyClicks(dialog.edit_widget, "abc") - async with qtrio.wait_signal_context(dialog.shown): + async with qtrio._core.wait_signal_context(dialog.shown): qtbot.mouseClick(dialog.ok_button, QtCore.Qt.LeftButton) qtbot.keyClicks(dialog.edit_widget, str(test_value)) @@ -86,7 +88,7 @@ async def test_file_save(request, qtbot, tmp_path): ) async def user(task_status): - async with qtrio.wait_signal_context(dialog.shown): + async with qtrio._core.wait_signal_context(dialog.shown): task_status.started() dialog.dialog.accept() @@ -111,7 +113,7 @@ async def test_information_message_box(request, qtbot): async def user(task_status): nonlocal queried_text - async with qtrio.wait_signal_context(dialog.shown): + async with qtrio._core.wait_signal_context(dialog.shown): task_status.started() queried_text = dialog.dialog.text() @@ -132,7 +134,7 @@ async def test_text_input_dialog(request, qtbot): entered_text = "etcetera" async def user(task_status): - async with qtrio.wait_signal_context(dialog.shown): + async with qtrio._core.wait_signal_context(dialog.shown): task_status.started() qtbot.keyClicks(dialog.line_edit, entered_text) From 8b7642f8b8097cc901d9364d6f39612951210e72 Mon Sep 17 00:00:00 2001 From: Kyle Altendorf Date: Mon, 20 Jul 2020 21:19:48 -0400 Subject: [PATCH 20/71] diagnostic always uses non-native --- qtrio/_dialogs.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/qtrio/_dialogs.py b/qtrio/_dialogs.py index 17c1a1b0..71dade75 100644 --- a/qtrio/_dialogs.py +++ b/qtrio/_dialogs.py @@ -199,7 +199,7 @@ def setup(self): extras["directory"] = os.fspath(self.default_directory) options = self.options - if sys.platform == "darwin": + if True:#sys.platform == "darwin": # https://github.com/altendky/qtrio/issues/28 options |= QtWidgets.QFileDialog.DontUseNativeDialog From ed14842c3692e03a3fcc46f5c5983c27ef3d5790 Mon Sep 17 00:00:00 2001 From: Kyle Altendorf Date: Fri, 24 Jul 2020 19:46:30 -0400 Subject: [PATCH 21/71] add newsfragments/2.feature.rst --- newsfragments/2.feature.rst | 1 + 1 file changed, 1 insertion(+) create mode 100644 newsfragments/2.feature.rst diff --git a/newsfragments/2.feature.rst b/newsfragments/2.feature.rst new file mode 100644 index 00000000..b5dc2c85 --- /dev/null +++ b/newsfragments/2.feature.rst @@ -0,0 +1 @@ +Introduce QTrio specific wrappers for some builtin dialogs. From 578a75f0516b742cbc1a4a9a2f108343dd2d62c3 Mon Sep 17 00:00:00 2001 From: Kyle Altendorf Date: Fri, 24 Jul 2020 19:47:38 -0400 Subject: [PATCH 22/71] Only non-native for macOS --- qtrio/_dialogs.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/qtrio/_dialogs.py b/qtrio/_dialogs.py index 71dade75..17c1a1b0 100644 --- a/qtrio/_dialogs.py +++ b/qtrio/_dialogs.py @@ -199,7 +199,7 @@ def setup(self): extras["directory"] = os.fspath(self.default_directory) options = self.options - if True:#sys.platform == "darwin": + if sys.platform == "darwin": # https://github.com/altendky/qtrio/issues/28 options |= QtWidgets.QFileDialog.DontUseNativeDialog From 6cd76158a4ae049dded73026d8e5480789e47cbf Mon Sep 17 00:00:00 2001 From: Kyle Altendorf Date: Fri, 24 Jul 2020 19:58:19 -0400 Subject: [PATCH 23/71] allow cancellation in the test --- qtrio/_tests/test_dialogs.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/qtrio/_tests/test_dialogs.py b/qtrio/_tests/test_dialogs.py index 0b0603a0..f37ffea8 100644 --- a/qtrio/_tests/test_dialogs.py +++ b/qtrio/_tests/test_dialogs.py @@ -91,6 +91,10 @@ async def user(task_status): async with qtrio._core.wait_signal_context(dialog.shown): task_status.started() + # allow cancellation to occur even if the signal was received before the + # cancellation was requested. + await trio.sleep(0) + dialog.dialog.accept() async with trio.open_nursery() as nursery: From 234705a21bf7339ccad8df67a9baf2e28ba98125 Mon Sep 17 00:00:00 2001 From: Kyle Altendorf Date: Fri, 24 Jul 2020 21:06:49 -0400 Subject: [PATCH 24/71] give it more time? --- qtrio/_tests/test_pytest.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/qtrio/_tests/test_pytest.py b/qtrio/_tests/test_pytest.py index 27fe6799..0e9ec9fb 100644 --- a/qtrio/_tests/test_pytest.py +++ b/qtrio/_tests/test_pytest.py @@ -10,11 +10,11 @@ def test_overrunning_test_times_out(preshow_testdir): @qtrio.host async def test(request): - await trio.sleep({2 * qtrio._pytest.timeout}) + await trio.sleep({10 * qtrio._pytest.timeout}) """ preshow_testdir.makepyfile(test_file) - result = preshow_testdir.runpytest_subprocess(timeout=2 * qtrio._pytest.timeout) + result = preshow_testdir.runpytest_subprocess(timeout=10 * qtrio._pytest.timeout) result.assert_outcomes(failed=1) result.stdout.re_match_lines( lines2=[r"E\s+qtrio\._exceptions\.RunnerTimedOutError"] From a06d76d823c054695bade6c7686e4409edf30b87 Mon Sep 17 00:00:00 2001 From: Kyle Altendorf Date: Fri, 31 Jul 2020 19:22:08 -0400 Subject: [PATCH 25/71] Raise timeout on failing tests --- qtrio/_tests/test_dialogs.py | 2 +- qtrio/examples/_tests/test_emissions.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/qtrio/_tests/test_dialogs.py b/qtrio/_tests/test_dialogs.py index f37ffea8..92582ce8 100644 --- a/qtrio/_tests/test_dialogs.py +++ b/qtrio/_tests/test_dialogs.py @@ -78,7 +78,7 @@ async def user(task_status): assert integer == test_value -@qtrio.host +@qtrio.host(timeout=10) async def test_file_save(request, qtbot, tmp_path): assert tmp_path.is_dir() path_to_select = trio.Path(tmp_path) / "something.new" diff --git a/qtrio/examples/_tests/test_emissions.py b/qtrio/examples/_tests/test_emissions.py index e80c6dda..75c48161 100644 --- a/qtrio/examples/_tests/test_emissions.py +++ b/qtrio/examples/_tests/test_emissions.py @@ -11,7 +11,7 @@ def test_main(preshow_testdir): import qtrio.examples.emissions - @qtrio.host + @qtrio.host(timeout=10) async def test_example(request, qtbot): window = qtrio.examples.emissions.Window.build() qtbot.addWidget(window.widget) From 8504f11598c746981703a820df8e7fe7e39abcee Mon Sep 17 00:00:00 2001 From: Kyle Altendorf Date: Fri, 31 Jul 2020 22:08:36 -0400 Subject: [PATCH 26/71] hmm --- qtrio/__init__.py | 1 + qtrio/_core.py | 6 +- qtrio/_dialogs.py | 105 ++++++++++++++--------------------- qtrio/_exceptions.py | 6 ++ qtrio/_tests/test_dialogs.py | 7 +-- 5 files changed, 57 insertions(+), 68 deletions(-) diff --git a/qtrio/__init__.py b/qtrio/__init__.py index bc16e849..f3cfff84 100644 --- a/qtrio/__init__.py +++ b/qtrio/__init__.py @@ -11,6 +11,7 @@ EventTypeAlreadyRegisteredError, ReturnCodeError, RunnerTimedOutError, + UserCancelledError, ) from ._core import ( diff --git a/qtrio/_core.py b/qtrio/_core.py index c6dff482..1e3c6b20 100644 --- a/qtrio/_core.py +++ b/qtrio/_core.py @@ -363,7 +363,11 @@ async def wait_signal_context( """ event = trio.Event() - with qtrio._qt.connection(signal=signal, slot=lambda *args, **kwargs: event.set()): + def slot(*args, **kwargs): + if not event.is_set(): + event.set() + + with qtrio._qt.connection(signal=signal, slot=slot): yield await event.wait() diff --git a/qtrio/_dialogs.py b/qtrio/_dialogs.py index 17c1a1b0..509fce32 100644 --- a/qtrio/_dialogs.py +++ b/qtrio/_dialogs.py @@ -12,6 +12,22 @@ import qtrio._qt +@contextlib.contextmanager +def manage(dialog): + finished_event = trio.Event() + + def slot(*args, **kwargs): + if not finished_event.is_set(): + finished_event.set() + + with qtrio._qt.connection(signal=dialog.finished, slot=slot): + try: + dialog.setup() + yield finished_event + finally: + dialog.teardown() + + @attr.s(auto_attribs=True) class IntegerDialog: parent: QtWidgets.QWidget @@ -62,34 +78,18 @@ def teardown(self): self.cancel_button = None self.edit_widget = None - @contextlib.contextmanager - def manage(self, finished_event=None): - with contextlib.ExitStack() as exit_stack: - if finished_event is not None: - exit_stack.enter_context( - qtrio._qt.connection( - signal=self.finished, - slot=lambda *args, **kwargs: finished_event.set(), - ) - ) - try: - self.setup() - yield self - finally: - self.teardown() - async def wait(self): while True: - finished_event = trio.Event() - with self.manage(finished_event=finished_event): + with manage(dialog=self) as finished_event: await finished_event.wait() + if self.dialog.result() != QtWidgets.QDialog.Accepted: - self.result = None - else: - try: - self.result = int(self.dialog.textValue()) - except ValueError: - continue + raise qtrio.UserCancelledError() + + try: + self.result = int(self.dialog.textValue()) + except ValueError: + continue return self.result @@ -104,9 +104,10 @@ class TextInputDialog: accept_button: typing.Optional[QtWidgets.QPushButton] = None reject_button: typing.Optional[QtWidgets.QPushButton] = None line_edit: typing.Optional[QtWidgets.QLineEdit] = None - result: typing.Optional[trio.Path] = None + result: typing.Optional[str] = None shown = qtrio._qt.Signal(QtWidgets.QInputDialog) + finished = qtrio._qt.Signal(int) # QtWidgets.QDialog.DialogCode def setup(self): self.result = None @@ -117,6 +118,8 @@ def setup(self): if self.title is not None: self.dialog.setWindowTitle(self.title) + # TODO: adjust so we can use a context manager? + self.dialog.finished.connect(self.finished) self.dialog.show() buttons = dialog_button_box_buttons_by_role(dialog=self.dialog) @@ -130,21 +133,16 @@ def setup(self): def teardown(self): if self.dialog is not None: self.dialog.close() + self.dialog.finished.disconnect(self.finished) self.dialog = None self.accept_button = None self.reject_button = None - @contextlib.contextmanager - def manage(self): - try: - self.setup() - yield self - finally: - self.teardown() - async def wait(self): - with self.manage(): - [result] = await qtrio._core.wait_signal(self.dialog.finished) + with manage(dialog=self) as finished_event: + await finished_event.wait() + + result = self.dialog.result() if result == QtWidgets.QDialog.Rejected: raise qtrio.UserCancelledError() @@ -232,25 +230,8 @@ def teardown(self): self.accept_button = None self.reject_button = None - @contextlib.contextmanager - def manage(self, finished_event=None): - with contextlib.ExitStack() as exit_stack: - if finished_event is not None: - exit_stack.enter_context( - qtrio._qt.connection( - signal=self.finished, - slot=lambda *args, **kwargs: finished_event.set(), - ) - ) - try: - self.setup() - yield self - finally: - self.teardown() - async def wait(self): - finished_event = trio.Event() - with self.manage(finished_event=finished_event): + with manage(dialog=self) as finished_event: await finished_event.wait() if self.dialog.result() != QtWidgets.QDialog.Accepted: self.result = None @@ -291,6 +272,7 @@ class MessageBox: result: typing.Optional[trio.Path] = None shown = qtrio._qt.Signal(QtWidgets.QMessageBox) + finished = qtrio._qt.Signal(int) # QtWidgets.QDialog.DialogCode def setup(self): self.result = None @@ -299,6 +281,9 @@ def setup(self): self.icon, self.title, self.text, self.buttons, self.parent ) + # TODO: adjust so we can use a context manager? + self.dialog.finished.connect(self.finished) + self.dialog.show() buttons = dialog_button_box_buttons_by_role(dialog=self.dialog) @@ -312,17 +297,11 @@ def teardown(self): self.dialog = None self.accept_button = None - @contextlib.contextmanager - def manage(self): - try: - self.setup() - yield self - finally: - self.teardown() - async def wait(self): - with self.manage(): - [result] = await qtrio._core.wait_signal(self.dialog.finished) + with manage(dialog=self) as finished_event: + await finished_event.wait() + + result = self.dialog.result() if result == QtWidgets.QDialog.Rejected: raise qtrio.UserCancelledError() diff --git a/qtrio/_exceptions.py b/qtrio/_exceptions.py index e10d8155..fca4becc 100644 --- a/qtrio/_exceptions.py +++ b/qtrio/_exceptions.py @@ -59,3 +59,9 @@ class RunnerTimedOutError(QTrioException): """Raised when a :class:`qtrio.Runner` times out the run.""" pass + + +class UserCancelledError(QTrioException): + """Raised when a user requested cancellation of an operation.""" + + pass diff --git a/qtrio/_tests/test_dialogs.py b/qtrio/_tests/test_dialogs.py index 92582ce8..b67326af 100644 --- a/qtrio/_tests/test_dialogs.py +++ b/qtrio/_tests/test_dialogs.py @@ -12,7 +12,7 @@ import qtrio._qt -@qtrio.host +@qtrio.host(timeout=99999) async def test_get_integer_gets_value(request, qtbot): dialog = qtrio._dialogs.IntegerDialog.build() @@ -47,9 +47,8 @@ async def user(task_status): async with trio.open_nursery() as nursery: await nursery.start(user) with qtrio._qt.connection(signal=dialog.shown, slot=qtbot.addWidget): - result = await dialog.wait() - - assert result is None + with pytest.raises(qtrio.UserCancelledError): + await dialog.wait() @qtrio.host From ce60d1f3f838d25a5883120e71bcaa24ef846299 Mon Sep 17 00:00:00 2001 From: Kyle Altendorf Date: Fri, 31 Jul 2020 22:48:27 -0400 Subject: [PATCH 27/71] do we like classmethods? --- qtrio/_dialogs.py | 77 +++++++++++++++++++----------------- qtrio/_tests/test_dialogs.py | 24 ++++++++--- 2 files changed, 59 insertions(+), 42 deletions(-) diff --git a/qtrio/_dialogs.py b/qtrio/_dialogs.py index 509fce32..5b8f35f3 100644 --- a/qtrio/_dialogs.py +++ b/qtrio/_dialogs.py @@ -72,7 +72,7 @@ def setup(self): def teardown(self): if self.dialog is not None: self.dialog.close() - self.dialog.finished.disconnect(self.finished) + self.dialog.finished.disconnect(self.finished) self.dialog = None self.ok_button = None self.cancel_button = None @@ -109,6 +109,15 @@ class TextInputDialog: shown = qtrio._qt.Signal(QtWidgets.QInputDialog) finished = qtrio._qt.Signal(int) # QtWidgets.QDialog.DialogCode + @classmethod + def build( + cls, + title: typing.Optional[str] = None, + label: typing.Optional[str] = None, + parent: typing.Optional[QtCore.QObject] = None, + ) -> "TextInputDialog": + return cls(title=title, label=label, parent=parent) + def setup(self): self.result = None @@ -133,7 +142,7 @@ def setup(self): def teardown(self): if self.dialog is not None: self.dialog.close() - self.dialog.finished.disconnect(self.finished) + self.dialog.finished.disconnect(self.finished) self.dialog = None self.accept_button = None self.reject_button = None @@ -152,14 +161,6 @@ async def wait(self): return self.result -def create_text_input_dialog( - title: typing.Optional[str] = None, - label: typing.Optional[str] = None, - parent: typing.Optional[QtCore.QObject] = None, -): - return TextInputDialog(title=title, label=label, parent=parent) - - def dialog_button_box_buttons_by_role( dialog: QtWidgets.QDialog, ) -> typing.Mapping[QtWidgets.QDialogButtonBox.ButtonRole, QtWidgets.QAbstractButton]: @@ -188,6 +189,23 @@ class FileDialog: shown = qtrio._qt.Signal(QtWidgets.QFileDialog) finished = qtrio._qt.Signal(int) # QtWidgets.QDialog.DialogCode + @classmethod + def build( + cls, + parent: typing.Optional[QtCore.QObject] = None, + default_directory: typing.Optional[trio.Path] = None, + default_file: typing.Optional[trio.Path] = None, + options: QtWidgets.QFileDialog.Options = QtWidgets.QFileDialog.Options(), + ) -> "FileDialog": + return cls( + parent=parent, + default_directory=default_directory, + default_file=default_file, + options=options, + file_mode=QtWidgets.QFileDialog.AnyFile, + accept_mode=QtWidgets.QFileDialog.AcceptSave, + ) + def setup(self): self.result = None @@ -225,7 +243,7 @@ def setup(self): def teardown(self): if self.dialog is not None: self.dialog.close() - self.dialog.finished.disconnect(self.finished) + self.dialog.finished.disconnect(self.finished) self.dialog = None self.accept_button = None self.reject_button = None @@ -242,22 +260,6 @@ async def wait(self): return self.result -def create_file_save_dialog( - parent: typing.Optional[QtCore.QObject] = None, - default_directory: typing.Optional[trio.Path] = None, - default_file: typing.Optional[trio.Path] = None, - options: QtWidgets.QFileDialog.Options = QtWidgets.QFileDialog.Options(), -): - return FileDialog( - parent=parent, - default_directory=default_directory, - default_file=default_file, - options=options, - file_mode=QtWidgets.QFileDialog.AnyFile, - accept_mode=QtWidgets.QFileDialog.AcceptSave, - ) - - @attr.s(auto_attribs=True) class MessageBox: icon: QtWidgets.QMessageBox.Icon @@ -274,6 +276,17 @@ class MessageBox: shown = qtrio._qt.Signal(QtWidgets.QMessageBox) finished = qtrio._qt.Signal(int) # QtWidgets.QDialog.DialogCode + @classmethod + def build_information( + cls, + title: str, + text: str, + icon: QtWidgets.QMessageBox.Icon = QtWidgets.QMessageBox.Information, + buttons: QtWidgets.QMessageBox.StandardButtons = QtWidgets.QMessageBox.Ok, + parent: typing.Optional[QtCore.QObject] = None, + ): + return cls(icon=icon, title=title, text=text, buttons=buttons, parent=parent) + def setup(self): self.result = None @@ -307,16 +320,6 @@ async def wait(self): raise qtrio.UserCancelledError() -def create_information_message_box( - icon: QtWidgets.QMessageBox.Icon, - title: str, - text: str, - buttons: QtWidgets.QMessageBox.StandardButtons = QtWidgets.QMessageBox.Ok, - parent: typing.Optional[QtCore.QObject] = None, -): - return MessageBox(icon=icon, title=title, text=text, buttons=buttons, parent=parent) - - @async_generator.asynccontextmanager async def manage_progress_dialog( title: str, diff --git a/qtrio/_tests/test_dialogs.py b/qtrio/_tests/test_dialogs.py index b67326af..44571190 100644 --- a/qtrio/_tests/test_dialogs.py +++ b/qtrio/_tests/test_dialogs.py @@ -12,7 +12,7 @@ import qtrio._qt -@qtrio.host(timeout=99999) +@qtrio.host async def test_get_integer_gets_value(request, qtbot): dialog = qtrio._dialogs.IntegerDialog.build() @@ -77,12 +77,26 @@ async def user(task_status): assert integer == test_value +@pytest.mark.parametrize( + argnames=["builder"], + argvalues=[ + [qtrio._dialogs.IntegerDialog.build], + [qtrio._dialogs.TextInputDialog.build], + [qtrio._dialogs.FileDialog.build], + [lambda: qtrio._dialogs.MessageBox.build_information(title="", text="")], + ], +) +def test_unused_dialog_teardown_ok(builder): + dialog = builder() + dialog.teardown() + + @qtrio.host(timeout=10) async def test_file_save(request, qtbot, tmp_path): assert tmp_path.is_dir() path_to_select = trio.Path(tmp_path) / "something.new" - dialog = qtrio._dialogs.create_file_save_dialog( + dialog = qtrio._dialogs.FileDialog.build( default_directory=path_to_select.parent, default_file=path_to_select, ) @@ -109,8 +123,8 @@ async def test_information_message_box(request, qtbot): text = "Consider yourself informed." queried_text = None - dialog = qtrio._dialogs.create_information_message_box( - icon=QtWidgets.QMessageBox.Information, title="Information", text=text, + dialog = qtrio._dialogs.MessageBox.build_information( + title="Information", text=text, icon=QtWidgets.QMessageBox.Information, ) async def user(task_status): @@ -132,7 +146,7 @@ async def user(task_status): @qtrio.host async def test_text_input_dialog(request, qtbot): - dialog = qtrio._dialogs.create_text_input_dialog() + dialog = qtrio._dialogs.TextInputDialog.build() entered_text = "etcetera" From 1f4966fa2b967ad7ce1e4ae959fbc0c0c6549fdb Mon Sep 17 00:00:00 2001 From: Kyle Altendorf Date: Fri, 31 Jul 2020 22:55:49 -0400 Subject: [PATCH 28/71] this will move elsewhere --- qtrio/_tests/test_pytest.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/qtrio/_tests/test_pytest.py b/qtrio/_tests/test_pytest.py index aa35c54e..9d2ea43d 100644 --- a/qtrio/_tests/test_pytest.py +++ b/qtrio/_tests/test_pytest.py @@ -18,11 +18,11 @@ def test_host_decoration_options(preshow_testdir, decorator_format): @{decorator_string} async def test(request): - True + pass """ preshow_testdir.makepyfile(test_file) - result = preshow_testdir.runpytest_subprocess(timeout=10) + result = preshow_testdir.runpytest_subprocess(timeout=20) result.assert_outcomes(passed=1) From 0670e890a386b608d2704998e6b1fa28a812c5c7 Mon Sep 17 00:00:00 2001 From: Kyle Altendorf Date: Sat, 1 Aug 2020 14:27:26 -0400 Subject: [PATCH 29/71] move UserCancelledError back where it was --- qtrio/_exceptions.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/qtrio/_exceptions.py b/qtrio/_exceptions.py index fca4becc..6b5665bc 100644 --- a/qtrio/_exceptions.py +++ b/qtrio/_exceptions.py @@ -55,13 +55,13 @@ def __eq__(self, other): return type(self) == type(other) and self.args == other.args -class RunnerTimedOutError(QTrioException): - """Raised when a :class:`qtrio.Runner` times out the run.""" +class UserCancelledError(QTrioException): + """Raised when a user requested cancellation of an operation.""" pass -class UserCancelledError(QTrioException): - """Raised when a user requested cancellation of an operation.""" +class RunnerTimedOutError(QTrioException): + """Raised when a :class:`qtrio.Runner` times out the run.""" pass From 9006352b3c65262e46a65d2457d8ac11c17f3dab Mon Sep 17 00:00:00 2001 From: Kyle Altendorf Date: Mon, 3 Aug 2020 20:27:11 -0400 Subject: [PATCH 30/71] 30 second timeout. ?!???!??!??!? --- qtrio/_tests/test_pytest.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/qtrio/_tests/test_pytest.py b/qtrio/_tests/test_pytest.py index deaafeeb..d14907ea 100644 --- a/qtrio/_tests/test_pytest.py +++ b/qtrio/_tests/test_pytest.py @@ -14,7 +14,6 @@ def test_host_decoration_options(testdir, decorator_format): test_file = rf""" import qtrio - import trio @{decorator_string} async def test(request): @@ -22,7 +21,7 @@ async def test(request): """ testdir.makepyfile(test_file) - result = testdir.runpytest_subprocess(timeout=10) + result = testdir.runpytest_subprocess(timeout=30) result.assert_outcomes(passed=1) From 5008764d5f98967cc3c52f11bfb5a50a3d44f3c3 Mon Sep 17 00:00:00 2001 From: Kyle Altendorf Date: Mon, 3 Aug 2020 21:00:16 -0400 Subject: [PATCH 31/71] 30 second timeout. ?!???!??!??!? --- qtrio/_tests/test_dialogs.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/qtrio/_tests/test_dialogs.py b/qtrio/_tests/test_dialogs.py index 44571190..e4e2cd95 100644 --- a/qtrio/_tests/test_dialogs.py +++ b/qtrio/_tests/test_dialogs.py @@ -12,7 +12,7 @@ import qtrio._qt -@qtrio.host +@qtrio.host(timeout=30) async def test_get_integer_gets_value(request, qtbot): dialog = qtrio._dialogs.IntegerDialog.build() From 8f30d503ca53a5ba6ac21e22245161fc37ae65f1 Mon Sep 17 00:00:00 2001 From: Kyle Altendorf Date: Mon, 3 Aug 2020 21:44:38 -0400 Subject: [PATCH 32/71] double up --- qtrio/_tests/test_dialogs.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/qtrio/_tests/test_dialogs.py b/qtrio/_tests/test_dialogs.py index e4e2cd95..be8da0c2 100644 --- a/qtrio/_tests/test_dialogs.py +++ b/qtrio/_tests/test_dialogs.py @@ -13,6 +13,27 @@ @qtrio.host(timeout=30) +async def test_blah(request, qtbot): + dialog = qtrio._dialogs.IntegerDialog.build() + + async def user(task_status): + async with qtrio._core.wait_signal_context(dialog.shown): + task_status.started() + + qtbot.keyClicks(dialog.edit_widget, str(test_value)) + qtbot.mouseClick(dialog.ok_button, QtCore.Qt.LeftButton) + + test_value = 928 + + async with trio.open_nursery() as nursery: + await nursery.start(user) + with qtrio._qt.connection(signal=dialog.shown, slot=qtbot.addWidget): + integer = await dialog.wait() + + assert integer == test_value + + +@qtrio.host async def test_get_integer_gets_value(request, qtbot): dialog = qtrio._dialogs.IntegerDialog.build() From 02258985e28dcbbdc7829692603979418ee883f0 Mon Sep 17 00:00:00 2001 From: Kyle Altendorf Date: Mon, 3 Aug 2020 21:51:37 -0400 Subject: [PATCH 33/71] less bleh --- qtrio/_tests/test_dialogs.py | 19 ++----------------- 1 file changed, 2 insertions(+), 17 deletions(-) diff --git a/qtrio/_tests/test_dialogs.py b/qtrio/_tests/test_dialogs.py index be8da0c2..ad8a99fd 100644 --- a/qtrio/_tests/test_dialogs.py +++ b/qtrio/_tests/test_dialogs.py @@ -13,24 +13,9 @@ @qtrio.host(timeout=30) -async def test_blah(request, qtbot): +async def test_blah(request): dialog = qtrio._dialogs.IntegerDialog.build() - - async def user(task_status): - async with qtrio._core.wait_signal_context(dialog.shown): - task_status.started() - - qtbot.keyClicks(dialog.edit_widget, str(test_value)) - qtbot.mouseClick(dialog.ok_button, QtCore.Qt.LeftButton) - - test_value = 928 - - async with trio.open_nursery() as nursery: - await nursery.start(user) - with qtrio._qt.connection(signal=dialog.shown, slot=qtbot.addWidget): - integer = await dialog.wait() - - assert integer == test_value + dialog.dialog.show() @qtrio.host From b599977d262f06ef3ea0492b9e54656b54bfc0a8 Mon Sep 17 00:00:00 2001 From: Kyle Altendorf Date: Mon, 3 Aug 2020 22:13:23 -0400 Subject: [PATCH 34/71] .setup() --- qtrio/_tests/test_dialogs.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/qtrio/_tests/test_dialogs.py b/qtrio/_tests/test_dialogs.py index ad8a99fd..001de9e5 100644 --- a/qtrio/_tests/test_dialogs.py +++ b/qtrio/_tests/test_dialogs.py @@ -15,7 +15,7 @@ @qtrio.host(timeout=30) async def test_blah(request): dialog = qtrio._dialogs.IntegerDialog.build() - dialog.dialog.show() + dialog.setup() @qtrio.host From 8641bcd894b10df11dc39f75fb451805c529058b Mon Sep 17 00:00:00 2001 From: Kyle Altendorf Date: Mon, 3 Aug 2020 22:30:49 -0400 Subject: [PATCH 35/71] general messagebox --- qtrio/_tests/test_dialogs.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/qtrio/_tests/test_dialogs.py b/qtrio/_tests/test_dialogs.py index 001de9e5..f9f09251 100644 --- a/qtrio/_tests/test_dialogs.py +++ b/qtrio/_tests/test_dialogs.py @@ -14,8 +14,11 @@ @qtrio.host(timeout=30) async def test_blah(request): - dialog = qtrio._dialogs.IntegerDialog.build() - dialog.setup() + dialog = QtWidgets.QMessageBox( + QtWidgets.QMessageBox.Information, "", "", QtWidgets.QMessageBox.Ok, + ) + dialog.show() + dialog.hide() @qtrio.host From 28bad29768acf2fabf88a7dc689d21939db71d95 Mon Sep 17 00:00:00 2001 From: Kyle Altendorf Date: Mon, 3 Aug 2020 23:02:34 -0400 Subject: [PATCH 36/71] maybe this time i'm done --- qtrio/_tests/helpers.py | 12 ++++++++++++ qtrio/_tests/test_dialogs.py | 9 --------- qtrio/conftest.py | 4 ++++ qtrio/examples/_tests/test_buildingrespect.py | 13 +++---------- 4 files changed, 19 insertions(+), 19 deletions(-) create mode 100644 qtrio/_tests/helpers.py diff --git a/qtrio/_tests/helpers.py b/qtrio/_tests/helpers.py new file mode 100644 index 00000000..36b17395 --- /dev/null +++ b/qtrio/_tests/helpers.py @@ -0,0 +1,12 @@ +import pytest +from qtpy import QtWidgets + + +@pytest.fixture(name="qtrio_preshow_workaround", scope="session", autouse=True) +def qtrio_preshow_workaround_fixture(qapp): + dialog = QtWidgets.QMessageBox( + QtWidgets.QMessageBox.Information, "", "", QtWidgets.QMessageBox.Ok, + ) + + dialog.show() + dialog.hide() diff --git a/qtrio/_tests/test_dialogs.py b/qtrio/_tests/test_dialogs.py index f9f09251..44571190 100644 --- a/qtrio/_tests/test_dialogs.py +++ b/qtrio/_tests/test_dialogs.py @@ -12,15 +12,6 @@ import qtrio._qt -@qtrio.host(timeout=30) -async def test_blah(request): - dialog = QtWidgets.QMessageBox( - QtWidgets.QMessageBox.Information, "", "", QtWidgets.QMessageBox.Ok, - ) - dialog.show() - dialog.hide() - - @qtrio.host async def test_get_integer_gets_value(request, qtbot): dialog = qtrio._dialogs.IntegerDialog.build() diff --git a/qtrio/conftest.py b/qtrio/conftest.py index 694d7d58..c77cadf9 100644 --- a/qtrio/conftest.py +++ b/qtrio/conftest.py @@ -1 +1,5 @@ +import qtrio._tests.helpers + pytest_plugins = "pytester" + +qtrio_preshow_workaround_fixture = qtrio._tests.helpers.qtrio_preshow_workaround_fixture diff --git a/qtrio/examples/_tests/test_buildingrespect.py b/qtrio/examples/_tests/test_buildingrespect.py index b5f31bc4..fd5e2e64 100644 --- a/qtrio/examples/_tests/test_buildingrespect.py +++ b/qtrio/examples/_tests/test_buildingrespect.py @@ -1,15 +1,8 @@ def test_main(testdir): conftest_file = r""" - import pytest - from qtpy import QtWidgets - - - @pytest.fixture(name="qtrio_preshow_workaround", scope="session", autouse=True) - def preshow_fixture(qapp): - widget = QtWidgets.QPushButton() - - widget.show() - widget.hide() + import qtrio._tests.helpers + + qtrio_preshow_workaround_fixture = qtrio._tests.helpers.qtrio_preshow_workaround_fixture """ testdir.makeconftest(conftest_file) From 9e333545225d421ae75a9728cf261e5c000c2034 Mon Sep 17 00:00:00 2001 From: Kyle Altendorf Date: Tue, 4 Aug 2020 09:07:01 -0400 Subject: [PATCH 37/71] remove the event already set check... again --- qtrio/_dialogs.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/qtrio/_dialogs.py b/qtrio/_dialogs.py index 5b8f35f3..f5e535f0 100644 --- a/qtrio/_dialogs.py +++ b/qtrio/_dialogs.py @@ -17,8 +17,7 @@ def manage(dialog): finished_event = trio.Event() def slot(*args, **kwargs): - if not finished_event.is_set(): - finished_event.set() + finished_event.set() with qtrio._qt.connection(signal=dialog.finished, slot=slot): try: From bc0b25bb9abc82c6f27f2a4d731d94b2aa0f731a Mon Sep 17 00:00:00 2001 From: Kyle Altendorf Date: Tue, 4 Aug 2020 13:53:40 -0400 Subject: [PATCH 38/71] text input dialog test coverage --- qtrio/_tests/test_dialogs.py | 59 ++++++++++++++++++++++++++++++------ 1 file changed, 50 insertions(+), 9 deletions(-) diff --git a/qtrio/_tests/test_dialogs.py b/qtrio/_tests/test_dialogs.py index 44571190..e557bebb 100644 --- a/qtrio/_tests/test_dialogs.py +++ b/qtrio/_tests/test_dialogs.py @@ -12,6 +12,19 @@ import qtrio._qt +@pytest.fixture( + name="builder", + params=[ + qtrio._dialogs.IntegerDialog.build, + qtrio._dialogs.TextInputDialog.build, + qtrio._dialogs.FileDialog.build, + lambda: qtrio._dialogs.MessageBox.build_information(title="", text=""), + ], +) +def builder_fixture(request): + yield request.param + + @qtrio.host async def test_get_integer_gets_value(request, qtbot): dialog = qtrio._dialogs.IntegerDialog.build() @@ -77,15 +90,6 @@ async def user(task_status): assert integer == test_value -@pytest.mark.parametrize( - argnames=["builder"], - argvalues=[ - [qtrio._dialogs.IntegerDialog.build], - [qtrio._dialogs.TextInputDialog.build], - [qtrio._dialogs.FileDialog.build], - [lambda: qtrio._dialogs.MessageBox.build_information(title="", text="")], - ], -) def test_unused_dialog_teardown_ok(builder): dialog = builder() dialog.teardown() @@ -163,3 +167,40 @@ async def user(task_status): returned_text = await dialog.wait() assert returned_text == entered_text + + +def test_text_input_dialog_with_title(): + title_string = "abc123" + dialog = qtrio._dialogs.TextInputDialog.build(title=title_string) + with qtrio._dialogs.manage(dialog=dialog): + assert dialog.dialog.windowTitle() == title_string + + +def test_text_input_dialog_with_label(): + label_string = "lmno789" + dialog = qtrio._dialogs.TextInputDialog.build(label=label_string) + with qtrio._dialogs.manage(dialog=dialog): + [label] = dialog.dialog.findChildren(QtWidgets.QLabel) + assert label.text() == label_string + + +@qtrio.host +async def test_text_input_dialog_cancel(request, qtbot): + dialog = qtrio._dialogs.TextInputDialog.build() + + async def user(task_status): + async with qtrio._core.wait_signal_context(dialog.shown): + task_status.started() + + dialog.dialog.reject() + + async with trio.open_nursery() as nursery: + await nursery.start(user) + with qtrio._qt.connection(signal=dialog.shown, slot=qtbot.addWidget): + with pytest.raises(qtrio.UserCancelledError): + returned_text = await dialog.wait() + + +def test_dialog_button_box_buttons_by_role_no_buttons(qtbot): + dialog = QtWidgets.QDialog() + assert qtrio._dialogs.dialog_button_box_buttons_by_role(dialog=dialog) == {} From bf614d775ba00d64774a17b5700f7bbe9cfc26af Mon Sep 17 00:00:00 2001 From: Kyle Altendorf Date: Tue, 4 Aug 2020 13:56:27 -0400 Subject: [PATCH 39/71] tidy --- qtrio/_tests/test_dialogs.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/qtrio/_tests/test_dialogs.py b/qtrio/_tests/test_dialogs.py index e557bebb..6fbccc5e 100644 --- a/qtrio/_tests/test_dialogs.py +++ b/qtrio/_tests/test_dialogs.py @@ -198,7 +198,7 @@ async def user(task_status): await nursery.start(user) with qtrio._qt.connection(signal=dialog.shown, slot=qtbot.addWidget): with pytest.raises(qtrio.UserCancelledError): - returned_text = await dialog.wait() + await dialog.wait() def test_dialog_button_box_buttons_by_role_no_buttons(qtbot): From a9187cd90bcb9e8a0d034250260ad99be8788568 Mon Sep 17 00:00:00 2001 From: Kyle Altendorf Date: Tue, 4 Aug 2020 17:40:17 -0400 Subject: [PATCH 40/71] some more test coverage --- qtrio/_dialogs.py | 8 ++--- qtrio/_tests/test_dialogs.py | 69 +++++++++++++++++++++++++++++++++++- 2 files changed, 72 insertions(+), 5 deletions(-) diff --git a/qtrio/_dialogs.py b/qtrio/_dialogs.py index f5e535f0..b487c485 100644 --- a/qtrio/_dialogs.py +++ b/qtrio/_dialogs.py @@ -251,10 +251,10 @@ async def wait(self): with manage(dialog=self) as finished_event: await finished_event.wait() if self.dialog.result() != QtWidgets.QDialog.Accepted: - self.result = None - else: - [path_string] = self.dialog.selectedFiles() - self.result = trio.Path(path_string) + raise qtrio.UserCancelledError() + + [path_string] = self.dialog.selectedFiles() + self.result = trio.Path(path_string) return self.result diff --git a/qtrio/_tests/test_dialogs.py b/qtrio/_tests/test_dialogs.py index 6fbccc5e..a94b3f7a 100644 --- a/qtrio/_tests/test_dialogs.py +++ b/qtrio/_tests/test_dialogs.py @@ -1,3 +1,4 @@ +import os import sys from qtpy import QtCore @@ -97,7 +98,6 @@ def test_unused_dialog_teardown_ok(builder): @qtrio.host(timeout=10) async def test_file_save(request, qtbot, tmp_path): - assert tmp_path.is_dir() path_to_select = trio.Path(tmp_path) / "something.new" dialog = qtrio._dialogs.FileDialog.build( @@ -122,6 +122,54 @@ async def user(task_status): assert selected_path == path_to_select +@qtrio.host(timeout=10) +async def test_file_save_no_defaults(request, qtbot, tmp_path): + path_to_select = trio.Path(tmp_path) / "another.thing" + + dialog = qtrio._dialogs.FileDialog.build() + + async def user(task_status): + async with qtrio._core.wait_signal_context(dialog.shown): + task_status.started() + + # allow cancellation to occur even if the signal was received before the + # cancellation was requested. + await trio.sleep(0) + + dialog.dialog.selectFile(os.fspath(path_to_select)) + dialog.dialog.accept() + + async with trio.open_nursery() as nursery: + await nursery.start(user) + with qtrio._qt.connection(signal=dialog.shown, slot=qtbot.addWidget): + selected_path = await dialog.wait() + + assert selected_path == path_to_select + + +@qtrio.host(timeout=10) +async def test_file_save_cancelled(request, qtbot, tmp_path): + path_to_select = trio.Path(tmp_path) / "another.thing" + + dialog = qtrio._dialogs.FileDialog.build() + + async def user(task_status): + async with qtrio._core.wait_signal_context(dialog.shown): + task_status.started() + + # allow cancellation to occur even if the signal was received before the + # cancellation was requested. + await trio.sleep(0) + + dialog.dialog.reject() + + async with trio.open_nursery() as nursery: + await nursery.start(user) + with qtrio._qt.connection(signal=dialog.shown, slot=qtbot.addWidget): + with pytest.raises(qtrio.UserCancelledError): + await dialog.wait() + + @qtrio.host async def test_information_message_box(request, qtbot): text = "Consider yourself informed." @@ -148,6 +196,25 @@ async def user(task_status): assert queried_text == text +@qtrio.host +async def test_information_message_box_cancel(request, qtbot): + dialog = qtrio._dialogs.MessageBox.build_information( + title="", text="", icon=QtWidgets.QMessageBox.Information, buttons=QtWidgets.QMessageBox.Ok | QtWidgets.QMessageBox.Cancel, + ) + + async def user(task_status): + async with qtrio._core.wait_signal_context(dialog.shown): + task_status.started() + + dialog.dialog.reject() + + async with trio.open_nursery() as nursery: + await nursery.start(user) + with qtrio._qt.connection(signal=dialog.shown, slot=qtbot.addWidget): + with pytest.raises(qtrio.UserCancelledError): + await dialog.wait() + + @qtrio.host async def test_text_input_dialog(request, qtbot): dialog = qtrio._dialogs.TextInputDialog.build() From b160a6148f6813e6ee1392e5d84e34bff6fbb21d Mon Sep 17 00:00:00 2001 From: Kyle Altendorf Date: Tue, 4 Aug 2020 17:47:05 -0400 Subject: [PATCH 41/71] black --- qtrio/_tests/test_dialogs.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/qtrio/_tests/test_dialogs.py b/qtrio/_tests/test_dialogs.py index a94b3f7a..f6e026b1 100644 --- a/qtrio/_tests/test_dialogs.py +++ b/qtrio/_tests/test_dialogs.py @@ -199,7 +199,10 @@ async def user(task_status): @qtrio.host async def test_information_message_box_cancel(request, qtbot): dialog = qtrio._dialogs.MessageBox.build_information( - title="", text="", icon=QtWidgets.QMessageBox.Information, buttons=QtWidgets.QMessageBox.Ok | QtWidgets.QMessageBox.Cancel, + title="", + text="", + icon=QtWidgets.QMessageBox.Information, + buttons=QtWidgets.QMessageBox.Ok | QtWidgets.QMessageBox.Cancel, ) async def user(task_status): From 256ac83f9d66b8aa1801cb6b48c090de103e6600 Mon Sep 17 00:00:00 2001 From: Kyle Altendorf Date: Tue, 4 Aug 2020 18:15:28 -0400 Subject: [PATCH 42/71] flake8 --- qtrio/_tests/test_dialogs.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/qtrio/_tests/test_dialogs.py b/qtrio/_tests/test_dialogs.py index f6e026b1..d1ddf997 100644 --- a/qtrio/_tests/test_dialogs.py +++ b/qtrio/_tests/test_dialogs.py @@ -149,8 +149,6 @@ async def user(task_status): @qtrio.host(timeout=10) async def test_file_save_cancelled(request, qtbot, tmp_path): - path_to_select = trio.Path(tmp_path) / "another.thing" - dialog = qtrio._dialogs.FileDialog.build() async def user(task_status): From f9f41f4beb704f2b2fb19a985dabe12cd33d91b1 Mon Sep 17 00:00:00 2001 From: Kyle Altendorf Date: Tue, 4 Aug 2020 19:53:54 -0400 Subject: [PATCH 43/71] report cancellation errors --- qtrio/_core.py | 33 ++++++++++++++++++--------------- 1 file changed, 18 insertions(+), 15 deletions(-) diff --git a/qtrio/_core.py b/qtrio/_core.py index 8150de84..e50d7907 100644 --- a/qtrio/_core.py +++ b/qtrio/_core.py @@ -578,24 +578,27 @@ async def trio_main( result = None timeout_cancel_scope = None - with trio.CancelScope() as self.cancel_scope: - with contextlib.ExitStack() as exit_stack: - if self.application.quitOnLastWindowClosed(): - exit_stack.enter_context( - qtrio._qt.connection( - signal=self.application.lastWindowClosed, - slot=self.cancel_scope.cancel, + try: + with trio.CancelScope() as self.cancel_scope: + with contextlib.ExitStack() as exit_stack: + if self.application.quitOnLastWindowClosed(): + exit_stack.enter_context( + qtrio._qt.connection( + signal=self.application.lastWindowClosed, + slot=self.cancel_scope.cancel, + ) + ) + if self.timeout is not None: + timeout_cancel_scope = exit_stack.enter_context( + trio.fail_after(self.timeout) ) - ) - if self.timeout is not None: - timeout_cancel_scope = exit_stack.enter_context( - trio.move_on_after(self.timeout) - ) - result = await async_fn(*args) + result = await async_fn(*args) + except trio.TooSlowError as e: + if timeout_cancel_scope is not None and timeout_cancel_scope.cancelled_caught: + raise qtrio.RunnerTimedOutError() from e - if timeout_cancel_scope is not None and timeout_cancel_scope.cancelled_caught: - raise qtrio.RunnerTimedOutError() + raise return result From 3f598d31120f964da3ad59c0134f92edd23e2759 Mon Sep 17 00:00:00 2001 From: Kyle Altendorf Date: Tue, 4 Aug 2020 20:09:39 -0400 Subject: [PATCH 44/71] longer timeout. again. really? --- qtrio/_tests/test_dialogs.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/qtrio/_tests/test_dialogs.py b/qtrio/_tests/test_dialogs.py index d1ddf997..2d011d99 100644 --- a/qtrio/_tests/test_dialogs.py +++ b/qtrio/_tests/test_dialogs.py @@ -122,7 +122,7 @@ async def user(task_status): assert selected_path == path_to_select -@qtrio.host(timeout=10) +@qtrio.host(timeout=30) async def test_file_save_no_defaults(request, qtbot, tmp_path): path_to_select = trio.Path(tmp_path) / "another.thing" From b397e955e4c17c9719c6d7b6ff049789979f583d Mon Sep 17 00:00:00 2001 From: Kyle Altendorf Date: Tue, 4 Aug 2020 19:05:45 -0700 Subject: [PATCH 45/71] findChildren for setting non-existent file name in windows --- qtrio/_tests/test_dialogs.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/qtrio/_tests/test_dialogs.py b/qtrio/_tests/test_dialogs.py index 2d011d99..19a6ad37 100644 --- a/qtrio/_tests/test_dialogs.py +++ b/qtrio/_tests/test_dialogs.py @@ -136,7 +136,9 @@ async def user(task_status): # cancellation was requested. await trio.sleep(0) - dialog.dialog.selectFile(os.fspath(path_to_select)) + dialog.dialog.setDirectory(os.fspath(path_to_select.parent)) + [text_edit] = dialog.dialog.findChildren(QtWidgets.QLineEdit) + text_edit.setText(path_to_select.name) dialog.dialog.accept() async with trio.open_nursery() as nursery: From 72863a1e769d9f942e2062904c9b6f3fb077d3b8 Mon Sep 17 00:00:00 2001 From: Kyle Altendorf Date: Tue, 4 Aug 2020 22:07:30 -0400 Subject: [PATCH 46/71] black --- qtrio/_core.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/qtrio/_core.py b/qtrio/_core.py index e50d7907..26b60798 100644 --- a/qtrio/_core.py +++ b/qtrio/_core.py @@ -595,7 +595,10 @@ async def trio_main( result = await async_fn(*args) except trio.TooSlowError as e: - if timeout_cancel_scope is not None and timeout_cancel_scope.cancelled_caught: + if ( + timeout_cancel_scope is not None + and timeout_cancel_scope.cancelled_caught + ): raise qtrio.RunnerTimedOutError() from e raise From e86ee99f22518ed4754514ddf4985b57b8a7dc91 Mon Sep 17 00:00:00 2001 From: Kyle Altendorf Date: Tue, 4 Aug 2020 19:36:05 -0700 Subject: [PATCH 47/71] monkeypatch codecs to figure out the full bytes --- qtrio/_tests/test_pytest.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/qtrio/_tests/test_pytest.py b/qtrio/_tests/test_pytest.py index d14907ea..a6070265 100644 --- a/qtrio/_tests/test_pytest.py +++ b/qtrio/_tests/test_pytest.py @@ -41,6 +41,18 @@ async def test(request): """ testdir.makepyfile(test_file) + def decode(self, input, final=False): + # decode input (taking the buffer into account) + data = self.buffer + input + print("-=-=-=-=-=-=-=-=-", repr(data)) + (result, consumed) = self._buffer_decode(data, self.errors, final) + # keep undecoded input until the next call + self.buffer = data[consumed:] + return result + + import codecs + codecs.BufferedIncrementalDecoder.decode = decode + result = testdir.runpytest_subprocess(timeout=4 * timeout) result.assert_outcomes(failed=1) result.stdout.re_match_lines( From 0a863db3dcaf21c0882170711207d2e72d68590e Mon Sep 17 00:00:00 2001 From: Kyle Altendorf Date: Wed, 5 Aug 2020 11:30:05 -0400 Subject: [PATCH 48/71] PYTHONIOENCODING --- .github/workflows/ci.yml | 1 + qtrio/_tests/test_pytest.py | 12 ------------ 2 files changed, 1 insertion(+), 12 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8b0365fd..0f29d269 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -110,6 +110,7 @@ jobs: # Should match 'name:' up above JOB_NAME: 'Windows (${{ matrix.python }}, ${{ matrix.arch }}, ${{ matrix.qt_library }})' INSTALL_EXTRAS: '[${{ matrix.qt_library }},tests]' + PYTHONIOENCODING: 'utf-8' Ubuntu: name: 'Ubuntu (${{ matrix.python }}, ${{ matrix.qt_library }})' diff --git a/qtrio/_tests/test_pytest.py b/qtrio/_tests/test_pytest.py index a6070265..d14907ea 100644 --- a/qtrio/_tests/test_pytest.py +++ b/qtrio/_tests/test_pytest.py @@ -41,18 +41,6 @@ async def test(request): """ testdir.makepyfile(test_file) - def decode(self, input, final=False): - # decode input (taking the buffer into account) - data = self.buffer + input - print("-=-=-=-=-=-=-=-=-", repr(data)) - (result, consumed) = self._buffer_decode(data, self.errors, final) - # keep undecoded input until the next call - self.buffer = data[consumed:] - return result - - import codecs - codecs.BufferedIncrementalDecoder.decode = decode - result = testdir.runpytest_subprocess(timeout=4 * timeout) result.assert_outcomes(failed=1) result.stdout.re_match_lines( From 11e1e02fdceba47e93c7eee9527fd6dc1ee28978 Mon Sep 17 00:00:00 2001 From: Kyle Altendorf Date: Wed, 5 Aug 2020 11:36:38 -0400 Subject: [PATCH 49/71] Add todo and link for PYTHONIOENCODING --- .github/workflows/ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0f29d269..d2c4b9ba 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -110,6 +110,7 @@ jobs: # Should match 'name:' up above JOB_NAME: 'Windows (${{ matrix.python }}, ${{ matrix.arch }}, ${{ matrix.qt_library }})' INSTALL_EXTRAS: '[${{ matrix.qt_library }},tests]' + # TODO: https://github.com/pytest-dev/pytest/issues/7623 PYTHONIOENCODING: 'utf-8' Ubuntu: From 33e19ef81c118133d3992b3e7470731d226183ea Mon Sep 17 00:00:00 2001 From: Kyle Altendorf Date: Wed, 5 Aug 2020 11:52:11 -0400 Subject: [PATCH 50/71] 100 --- ci.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ci.sh b/ci.sh index 4f494839..b82fc814 100755 --- a/ci.sh +++ b/ci.sh @@ -47,7 +47,7 @@ python -m pip list python -m pip freeze if [ "$CHECK_DOCS" = "1" ]; then - git fetch --depth=1 origin master + git fetch --depth=100 origin master towncrier check # https://github.com/twisted/towncrier/pull/271 towncrier build --yes --name QTrio # catch errors in newsfragments From 7710ba0e6c68af4f2ea8cf79443fbb36edb578d1 Mon Sep 17 00:00:00 2001 From: Kyle Altendorf Date: Wed, 5 Aug 2020 12:09:55 -0400 Subject: [PATCH 51/71] HEAD too --- ci.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/ci.sh b/ci.sh index b82fc814..da4c1404 100755 --- a/ci.sh +++ b/ci.sh @@ -47,6 +47,7 @@ python -m pip list python -m pip freeze if [ "$CHECK_DOCS" = "1" ]; then + git fetch --depth=100 HEAD git fetch --depth=100 origin master towncrier check # https://github.com/twisted/towncrier/pull/271 From cd8e33c03d4be66b1cb3883c3aa7c7adbd14c126 Mon Sep 17 00:00:00 2001 From: Kyle Altendorf Date: Wed, 5 Aug 2020 12:12:01 -0400 Subject: [PATCH 52/71] no HEAD? --- ci.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ci.sh b/ci.sh index da4c1404..838259ed 100755 --- a/ci.sh +++ b/ci.sh @@ -47,7 +47,7 @@ python -m pip list python -m pip freeze if [ "$CHECK_DOCS" = "1" ]; then - git fetch --depth=100 HEAD + git fetch --depth=100 git fetch --depth=100 origin master towncrier check # https://github.com/twisted/towncrier/pull/271 From d713284275b482e55876cd1f88f0e56972c1e660 Mon Sep 17 00:00:00 2001 From: Kyle Altendorf Date: Wed, 5 Aug 2020 12:30:02 -0400 Subject: [PATCH 53/71] deepen --- ci.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ci.sh b/ci.sh index 838259ed..d56fe3c4 100755 --- a/ci.sh +++ b/ci.sh @@ -47,7 +47,7 @@ python -m pip list python -m pip freeze if [ "$CHECK_DOCS" = "1" ]; then - git fetch --depth=100 + git fetch --deepen=100 git fetch --depth=100 origin master towncrier check # https://github.com/twisted/towncrier/pull/271 From cb05565241ca312097fc7da42a080b473b18c64c Mon Sep 17 00:00:00 2001 From: Kyle Altendorf Date: Wed, 5 Aug 2020 15:21:11 -0400 Subject: [PATCH 54/71] remove manage_progress_dialog --- qtrio/_dialogs.py | 19 ------------------- 1 file changed, 19 deletions(-) diff --git a/qtrio/_dialogs.py b/qtrio/_dialogs.py index b487c485..633d7ee9 100644 --- a/qtrio/_dialogs.py +++ b/qtrio/_dialogs.py @@ -317,22 +317,3 @@ async def wait(self): if result == QtWidgets.QDialog.Rejected: raise qtrio.UserCancelledError() - - -@async_generator.asynccontextmanager -async def manage_progress_dialog( - title: str, - label: str, - minimum: int = 0, - maximum: int = 0, - cancel_button_text: str = "Cancel", - parent: QtCore.QObject = None, -): - dialog = QtWidgets.QProgressDialog( - label, cancel_button_text, minimum, maximum, parent - ) - try: - dialog.setWindowTitle(title) - yield dialog - finally: - dialog.close() From 3fb467fa409809563f4ada68d88f6a6cc608d85e Mon Sep 17 00:00:00 2001 From: Kyle Altendorf Date: Wed, 5 Aug 2020 16:18:14 -0400 Subject: [PATCH 55/71] Add test for non-timeout trio.TooSlowError passing out --- qtrio/_tests/test_core.py | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/qtrio/_tests/test_core.py b/qtrio/_tests/test_core.py index 3d8970ae..db6f2f06 100644 --- a/qtrio/_tests/test_core.py +++ b/qtrio/_tests/test_core.py @@ -170,6 +170,33 @@ async def main(): result.assert_outcomes(passed=1) +def test_run_passes_internal_too_slow_error(testdir): + """The async function run by :func:`qtrio.run` is executed in the Qt host thread.""" + + test_file = r""" + import math + import pytest + + import qtrio + import trio + + + def test(): + async def main(): + with trio.fail_after(0): + await trio.sleep(math.inf) + + outcomes = qtrio.run(main) + + with pytest.raises(trio.TooSlowError): + outcomes.unwrap() + """ + testdir.makepyfile(test_file) + + result = testdir.runpytest_subprocess(timeout=timeout) + result.assert_outcomes(passed=1) + + def test_run_runs_in_main_thread(testdir): """The async function run by :func:`qtrio.run` is executed in the Qt host thread.""" From 22358fbeb1d94ff76c910ac44ed43a2521338a8e Mon Sep 17 00:00:00 2001 From: Kyle Altendorf Date: Wed, 5 Aug 2020 20:14:12 -0400 Subject: [PATCH 56/71] please let me not change this timeout right now --- qtrio/_tests/test_pytest.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/qtrio/_tests/test_pytest.py b/qtrio/_tests/test_pytest.py index d14907ea..bdbbe3e1 100644 --- a/qtrio/_tests/test_pytest.py +++ b/qtrio/_tests/test_pytest.py @@ -21,7 +21,7 @@ async def test(request): """ testdir.makepyfile(test_file) - result = testdir.runpytest_subprocess(timeout=30) + result = testdir.runpytest_subprocess(timeout=10) result.assert_outcomes(passed=1) From 6d7ca4c0ff0b644daf1ceed70e1d54aeb0cc6eb9 Mon Sep 17 00:00:00 2001 From: Kyle Altendorf Date: Wed, 5 Aug 2020 21:02:11 -0400 Subject: [PATCH 57/71] make qtrio.dialogs public --- qtrio/_tests/test_dialogs.py | 40 +++++++++++++++---------------- qtrio/{_dialogs.py => dialogs.py} | 0 2 files changed, 20 insertions(+), 20 deletions(-) rename qtrio/{_dialogs.py => dialogs.py} (100%) diff --git a/qtrio/_tests/test_dialogs.py b/qtrio/_tests/test_dialogs.py index 19a6ad37..31707d64 100644 --- a/qtrio/_tests/test_dialogs.py +++ b/qtrio/_tests/test_dialogs.py @@ -9,17 +9,17 @@ import qtrio import qtrio._core -import qtrio._dialogs +import qtrio.dialogs import qtrio._qt @pytest.fixture( name="builder", params=[ - qtrio._dialogs.IntegerDialog.build, - qtrio._dialogs.TextInputDialog.build, - qtrio._dialogs.FileDialog.build, - lambda: qtrio._dialogs.MessageBox.build_information(title="", text=""), + qtrio.dialogs.IntegerDialog.build, + qtrio.dialogs.TextInputDialog.build, + qtrio.dialogs.FileDialog.build, + lambda: qtrio.dialogs.MessageBox.build_information(title="", text=""), ], ) def builder_fixture(request): @@ -28,7 +28,7 @@ def builder_fixture(request): @qtrio.host async def test_get_integer_gets_value(request, qtbot): - dialog = qtrio._dialogs.IntegerDialog.build() + dialog = qtrio.dialogs.IntegerDialog.build() async def user(task_status): async with qtrio._core.wait_signal_context(dialog.shown): @@ -49,7 +49,7 @@ async def user(task_status): @qtrio.host async def test_get_integer_raises_cancel_when_canceled(request, qtbot): - dialog = qtrio._dialogs.IntegerDialog.build() + dialog = qtrio.dialogs.IntegerDialog.build() async def user(task_status): async with qtrio._core.wait_signal_context(dialog.shown): @@ -67,7 +67,7 @@ async def user(task_status): @qtrio.host async def test_get_integer_gets_value_after_retry(request, qtbot): - dialog = qtrio._dialogs.IntegerDialog.build() + dialog = qtrio.dialogs.IntegerDialog.build() test_value = 928 @@ -100,7 +100,7 @@ def test_unused_dialog_teardown_ok(builder): async def test_file_save(request, qtbot, tmp_path): path_to_select = trio.Path(tmp_path) / "something.new" - dialog = qtrio._dialogs.FileDialog.build( + dialog = qtrio.dialogs.FileDialog.build( default_directory=path_to_select.parent, default_file=path_to_select, ) @@ -126,7 +126,7 @@ async def user(task_status): async def test_file_save_no_defaults(request, qtbot, tmp_path): path_to_select = trio.Path(tmp_path) / "another.thing" - dialog = qtrio._dialogs.FileDialog.build() + dialog = qtrio.dialogs.FileDialog.build() async def user(task_status): async with qtrio._core.wait_signal_context(dialog.shown): @@ -151,7 +151,7 @@ async def user(task_status): @qtrio.host(timeout=10) async def test_file_save_cancelled(request, qtbot, tmp_path): - dialog = qtrio._dialogs.FileDialog.build() + dialog = qtrio.dialogs.FileDialog.build() async def user(task_status): async with qtrio._core.wait_signal_context(dialog.shown): @@ -175,7 +175,7 @@ async def test_information_message_box(request, qtbot): text = "Consider yourself informed." queried_text = None - dialog = qtrio._dialogs.MessageBox.build_information( + dialog = qtrio.dialogs.MessageBox.build_information( title="Information", text=text, icon=QtWidgets.QMessageBox.Information, ) @@ -198,7 +198,7 @@ async def user(task_status): @qtrio.host async def test_information_message_box_cancel(request, qtbot): - dialog = qtrio._dialogs.MessageBox.build_information( + dialog = qtrio.dialogs.MessageBox.build_information( title="", text="", icon=QtWidgets.QMessageBox.Information, @@ -220,7 +220,7 @@ async def user(task_status): @qtrio.host async def test_text_input_dialog(request, qtbot): - dialog = qtrio._dialogs.TextInputDialog.build() + dialog = qtrio.dialogs.TextInputDialog.build() entered_text = "etcetera" @@ -241,22 +241,22 @@ async def user(task_status): def test_text_input_dialog_with_title(): title_string = "abc123" - dialog = qtrio._dialogs.TextInputDialog.build(title=title_string) - with qtrio._dialogs.manage(dialog=dialog): + dialog = qtrio.dialogs.TextInputDialog.build(title=title_string) + with qtrio.dialogs.manage(dialog=dialog): assert dialog.dialog.windowTitle() == title_string def test_text_input_dialog_with_label(): label_string = "lmno789" - dialog = qtrio._dialogs.TextInputDialog.build(label=label_string) - with qtrio._dialogs.manage(dialog=dialog): + dialog = qtrio.dialogs.TextInputDialog.build(label=label_string) + with qtrio.dialogs.manage(dialog=dialog): [label] = dialog.dialog.findChildren(QtWidgets.QLabel) assert label.text() == label_string @qtrio.host async def test_text_input_dialog_cancel(request, qtbot): - dialog = qtrio._dialogs.TextInputDialog.build() + dialog = qtrio.dialogs.TextInputDialog.build() async def user(task_status): async with qtrio._core.wait_signal_context(dialog.shown): @@ -273,4 +273,4 @@ async def user(task_status): def test_dialog_button_box_buttons_by_role_no_buttons(qtbot): dialog = QtWidgets.QDialog() - assert qtrio._dialogs.dialog_button_box_buttons_by_role(dialog=dialog) == {} + assert qtrio.dialogs.dialog_button_box_buttons_by_role(dialog=dialog) == {} diff --git a/qtrio/_dialogs.py b/qtrio/dialogs.py similarity index 100% rename from qtrio/_dialogs.py rename to qtrio/dialogs.py From 30b9944876e6beb3535c359475c79495f41e1d8d Mon Sep 17 00:00:00 2001 From: Kyle Altendorf Date: Thu, 6 Aug 2020 10:30:28 -0400 Subject: [PATCH 58/71] remove IntegerDialog retry feature, use tenacity or such --- qtrio/__init__.py | 1 + qtrio/_exceptions.py | 6 ++++++ qtrio/_tests/test_dialogs.py | 14 +++----------- qtrio/dialogs.py | 19 +++++++++---------- 4 files changed, 19 insertions(+), 21 deletions(-) diff --git a/qtrio/__init__.py b/qtrio/__init__.py index f3cfff84..abcebbb2 100644 --- a/qtrio/__init__.py +++ b/qtrio/__init__.py @@ -12,6 +12,7 @@ ReturnCodeError, RunnerTimedOutError, UserCancelledError, + InvalidInputError, ) from ._core import ( diff --git a/qtrio/_exceptions.py b/qtrio/_exceptions.py index 6b5665bc..bc40e989 100644 --- a/qtrio/_exceptions.py +++ b/qtrio/_exceptions.py @@ -65,3 +65,9 @@ class RunnerTimedOutError(QTrioException): """Raised when a :class:`qtrio.Runner` times out the run.""" pass + + +class InvalidInputError(QTrioException): + """Raised when invalid input is provided such as via a dialog.""" + + pass diff --git a/qtrio/_tests/test_dialogs.py b/qtrio/_tests/test_dialogs.py index 31707d64..fd4d6ab8 100644 --- a/qtrio/_tests/test_dialogs.py +++ b/qtrio/_tests/test_dialogs.py @@ -66,29 +66,21 @@ async def user(task_status): @qtrio.host -async def test_get_integer_gets_value_after_retry(request, qtbot): +async def test_get_integer_raises_for_invalid_input(request, qtbot): dialog = qtrio.dialogs.IntegerDialog.build() - test_value = 928 - async def user(task_status): async with qtrio._core.wait_signal_context(dialog.shown): task_status.started() qtbot.keyClicks(dialog.edit_widget, "abc") - - async with qtrio._core.wait_signal_context(dialog.shown): - qtbot.mouseClick(dialog.ok_button, QtCore.Qt.LeftButton) - - qtbot.keyClicks(dialog.edit_widget, str(test_value)) qtbot.mouseClick(dialog.ok_button, QtCore.Qt.LeftButton) async with trio.open_nursery() as nursery: await nursery.start(user) with qtrio._qt.connection(signal=dialog.shown, slot=qtbot.addWidget): - integer = await dialog.wait() - - assert integer == test_value + with pytest.raises(qtrio.InvalidInputError): + await dialog.wait() def test_unused_dialog_teardown_ok(builder): diff --git a/qtrio/dialogs.py b/qtrio/dialogs.py index 633d7ee9..070985ec 100644 --- a/qtrio/dialogs.py +++ b/qtrio/dialogs.py @@ -78,19 +78,18 @@ def teardown(self): self.edit_widget = None async def wait(self): - while True: - with manage(dialog=self) as finished_event: - await finished_event.wait() + with manage(dialog=self) as finished_event: + await finished_event.wait() - if self.dialog.result() != QtWidgets.QDialog.Accepted: - raise qtrio.UserCancelledError() + if self.dialog.result() != QtWidgets.QDialog.Accepted: + raise qtrio.UserCancelledError() - try: - self.result = int(self.dialog.textValue()) - except ValueError: - continue + try: + self.result = int(self.dialog.textValue()) + except ValueError: + raise qtrio.InvalidInputError() - return self.result + return self.result @attr.s(auto_attribs=True) From 8e9781d09262ab9515a0e640ec4eec506eb0769a Mon Sep 17 00:00:00 2001 From: Kyle Altendorf Date: Thu, 6 Aug 2020 11:29:07 -0400 Subject: [PATCH 59/71] remove attempts --- qtrio/dialogs.py | 7 ------- 1 file changed, 7 deletions(-) diff --git a/qtrio/dialogs.py b/qtrio/dialogs.py index 070985ec..43c0ae1e 100644 --- a/qtrio/dialogs.py +++ b/qtrio/dialogs.py @@ -34,11 +34,9 @@ class IntegerDialog: edit_widget: typing.Optional[QtWidgets.QWidget] = None ok_button: typing.Optional[QtWidgets.QPushButton] = None cancel_button: typing.Optional[QtWidgets.QPushButton] = None - attempt: typing.Optional[int] = None result: typing.Optional[int] = None shown = qtrio._qt.Signal(QtWidgets.QInputDialog) - hidden = qtrio._qt.Signal() finished = qtrio._qt.Signal(int) # QtWidgets.QDialog.DialogCode @classmethod @@ -61,11 +59,6 @@ def setup(self): [self.edit_widget] = self.dialog.findChildren(QtWidgets.QLineEdit) - if self.attempt is None: - self.attempt = 0 - else: - self.attempt += 1 - self.shown.emit(self.dialog) def teardown(self): From 61944c2f2ee78c4f1d1ef2faf14a9830ccc4358f Mon Sep 17 00:00:00 2001 From: Kyle Altendorf Date: Sun, 9 Aug 2020 19:57:16 -0400 Subject: [PATCH 60/71] stuff --- docs/source/dialogs.rst | 8 ++ docs/source/exceptions.rst | 1 + docs/source/index.rst | 1 + qtrio/__init__.py | 1 + qtrio/_exceptions.py | 4 + qtrio/_tests/test_dialogs.py | 27 +++++- qtrio/dialogs.py | 172 +++++++++++++++++++++++++---------- 7 files changed, 161 insertions(+), 53 deletions(-) create mode 100644 docs/source/dialogs.rst diff --git a/docs/source/dialogs.rst b/docs/source/dialogs.rst new file mode 100644 index 00000000..817b2e3a --- /dev/null +++ b/docs/source/dialogs.rst @@ -0,0 +1,8 @@ +Dialogs +======= + + +.. autoclass:: qtrio.dialogs.IntegerDialog +.. autoclass:: qtrio.dialogs.TextInputDialog +.. autoclass:: qtrio.dialogs.FileDialog +.. autoclass:: qtrio.dialogs.MessageBox diff --git a/docs/source/exceptions.rst b/docs/source/exceptions.rst index 3bab3a76..75ebacc0 100644 --- a/docs/source/exceptions.rst +++ b/docs/source/exceptions.rst @@ -8,3 +8,4 @@ Exceptions .. autoclass:: qtrio.RequestedEventTypeUnavailableError .. autoclass:: qtrio.EventTypeAlreadyRegisteredError .. autoclass:: qtrio.ReturnCodeError +.. autoclass:: qtrio.InternalError diff --git a/docs/source/index.rst b/docs/source/index.rst index b4d4a5d6..e7f9c18b 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -12,6 +12,7 @@ tutorial.rst core.rst testing.rst + dialogs.rst exceptions.rst examples/examples.rst development/index.rst diff --git a/qtrio/__init__.py b/qtrio/__init__.py index abcebbb2..2c38e249 100644 --- a/qtrio/__init__.py +++ b/qtrio/__init__.py @@ -13,6 +13,7 @@ RunnerTimedOutError, UserCancelledError, InvalidInputError, + InternalError, ) from ._core import ( diff --git a/qtrio/_exceptions.py b/qtrio/_exceptions.py index a8580cd6..9c6c3eb5 100644 --- a/qtrio/_exceptions.py +++ b/qtrio/_exceptions.py @@ -84,3 +84,7 @@ class InvalidInputError(QTrioException): """Raised when invalid input is provided such as via a dialog.""" pass + + +class InternalError(QTrioException): + """Raised when an internal state is inconsistent.""" diff --git a/qtrio/_tests/test_dialogs.py b/qtrio/_tests/test_dialogs.py index fd4d6ab8..b50b41c0 100644 --- a/qtrio/_tests/test_dialogs.py +++ b/qtrio/_tests/test_dialogs.py @@ -85,7 +85,7 @@ async def user(task_status): def test_unused_dialog_teardown_ok(builder): dialog = builder() - dialog.teardown() + dialog._teardown() @qtrio.host(timeout=10) @@ -104,6 +104,8 @@ async def user(task_status): # cancellation was requested. await trio.sleep(0) + assert dialog.dialog is not None + dialog.dialog.accept() async with trio.open_nursery() as nursery: @@ -128,6 +130,8 @@ async def user(task_status): # cancellation was requested. await trio.sleep(0) + assert dialog.dialog is not None + dialog.dialog.setDirectory(os.fspath(path_to_select.parent)) [text_edit] = dialog.dialog.findChildren(QtWidgets.QLineEdit) text_edit.setText(path_to_select.name) @@ -153,6 +157,8 @@ async def user(task_status): # cancellation was requested. await trio.sleep(0) + assert dialog.dialog is not None + dialog.dialog.reject() async with trio.open_nursery() as nursery: @@ -177,6 +183,8 @@ async def user(task_status): async with qtrio._core.wait_signal_context(dialog.shown): task_status.started() + assert dialog.dialog is not None + queried_text = dialog.dialog.text() dialog.dialog.accept() @@ -201,6 +209,8 @@ async def user(task_status): async with qtrio._core.wait_signal_context(dialog.shown): task_status.started() + assert dialog.dialog is not None + dialog.dialog.reject() async with trio.open_nursery() as nursery: @@ -221,6 +231,9 @@ async def user(task_status): task_status.started() qtbot.keyClicks(dialog.line_edit, entered_text) + + assert dialog.dialog is not None + dialog.dialog.accept() async with trio.open_nursery() as nursery: @@ -234,14 +247,18 @@ async def user(task_status): def test_text_input_dialog_with_title(): title_string = "abc123" dialog = qtrio.dialogs.TextInputDialog.build(title=title_string) - with qtrio.dialogs.manage(dialog=dialog): + with qtrio.dialogs._manage(dialog=dialog): + assert dialog.dialog is not None + assert dialog.dialog.windowTitle() == title_string def test_text_input_dialog_with_label(): label_string = "lmno789" dialog = qtrio.dialogs.TextInputDialog.build(label=label_string) - with qtrio.dialogs.manage(dialog=dialog): + with qtrio.dialogs._manage(dialog=dialog): + assert dialog.dialog is not None + [label] = dialog.dialog.findChildren(QtWidgets.QLabel) assert label.text() == label_string @@ -254,6 +271,8 @@ async def user(task_status): async with qtrio._core.wait_signal_context(dialog.shown): task_status.started() + assert dialog.dialog is not None + dialog.dialog.reject() async with trio.open_nursery() as nursery: @@ -265,4 +284,4 @@ async def user(task_status): def test_dialog_button_box_buttons_by_role_no_buttons(qtbot): dialog = QtWidgets.QDialog() - assert qtrio.dialogs.dialog_button_box_buttons_by_role(dialog=dialog) == {} + assert qtrio.dialogs._dialog_button_box_buttons_by_role(dialog=dialog) == {} diff --git a/qtrio/dialogs.py b/qtrio/dialogs.py index 43c0ae1e..51ccbcf0 100644 --- a/qtrio/dialogs.py +++ b/qtrio/dialogs.py @@ -1,3 +1,4 @@ +import abc import contextlib import os import sys @@ -12,28 +13,70 @@ import qtrio._qt +class AbstractDialog: + @property + @abc.abstractmethod + def finished(self) -> qtrio._qt.Signal: + # should really be QtWidgets.QDialog.DialogCode not int + pass + + @abc.abstractmethod + def _setup(self) -> None: + ... + + @abc.abstractmethod + def _teardown(self) -> None: + ... + + @contextlib.contextmanager -def manage(dialog): +def _manage(dialog: AbstractDialog) -> typing.Generator[trio.Event, None, None]: finished_event = trio.Event() - def slot(*args, **kwargs): + def slot(*args: object, **kwargs: object) -> None: finished_event.set() with qtrio._qt.connection(signal=dialog.finished, slot=slot): try: - dialog.setup() + dialog._setup() yield finished_event finally: - dialog.teardown() + dialog._teardown() + + +def _dialog_button_box_buttons_by_role( + dialog: QtWidgets.QDialog, +) -> typing.Mapping[QtWidgets.QDialogButtonBox.ButtonRole, QtWidgets.QAbstractButton]: + hits = dialog.findChildren(QtWidgets.QDialogButtonBox) + + if len(hits) == 0: + return {} + + [button_box] = hits + return {button_box.buttonRole(button): button for button in button_box.buttons()} @attr.s(auto_attribs=True) -class IntegerDialog: +class IntegerDialog(AbstractDialog): + """Manage a dialog for inputting an integer from the user. + + Attributes: + parent: The parent widget for the dialog. + dialog: The actual dialog widget instance. + edit_widget: The line edit that the user will enter the input into. + ok_button: The entry confirmation button. + cancel_button: The input cancellation button. + result: The result of parsing the user input. + shown: The signal emitted when the dialog is shown. + finished: The signal emitted when the dialog is finished. + """ parent: QtWidgets.QWidget + dialog: typing.Optional[QtWidgets.QInputDialog] = None - edit_widget: typing.Optional[QtWidgets.QWidget] = None + edit_widget: typing.Optional[QtWidgets.QLineEdit] = None ok_button: typing.Optional[QtWidgets.QPushButton] = None cancel_button: typing.Optional[QtWidgets.QPushButton] = None + result: typing.Optional[int] = None shown = qtrio._qt.Signal(QtWidgets.QInputDialog) @@ -41,9 +84,13 @@ class IntegerDialog: @classmethod def build(cls, parent: QtCore.QObject = None,) -> "IntegerDialog": - return cls(parent=parent) + return IntegerDialog(parent=parent) - def setup(self): + def _setup(self) -> None: + """Create the actual dialog widget and perform related setup activities + including showing the widget and emitting + :attr:`qtrio.dialogs.IntegerDialog.shown` when done. + """ self.result = None self.dialog = QtWidgets.QInputDialog(self.parent) @@ -53,7 +100,7 @@ def setup(self): self.dialog.show() - buttons = dialog_button_box_buttons_by_role(dialog=self.dialog) + buttons = _dialog_button_box_buttons_by_role(dialog=self.dialog) self.ok_button = buttons.get(QtWidgets.QDialogButtonBox.AcceptRole) self.cancel_button = buttons.get(QtWidgets.QDialogButtonBox.RejectRole) @@ -61,7 +108,8 @@ def setup(self): self.shown.emit(self.dialog) - def teardown(self): + def _teardown(self) -> None: + """Teardown the dialog, signal and slot connections, widget references, etc.""" if self.dialog is not None: self.dialog.close() self.dialog.finished.disconnect(self.finished) @@ -70,8 +118,16 @@ def teardown(self): self.cancel_button = None self.edit_widget = None - async def wait(self): - with manage(dialog=self) as finished_event: + async def wait(self) -> int: + """Setup the dialog, wait for the user input, teardown, and return the user + input. Raises :class:`qtrio.UserCancelledError` if the user cancels the dialog. + Raises :class:`qtrio.InvalidInputError` if the input can't be parsed as an + integer. + """ + with _manage(dialog=self) as finished_event: + if self.dialog is None: + raise qtrio.InternalError("Dialog not assigned while it is being managed.") + await finished_event.wait() if self.dialog.result() != QtWidgets.QDialog.Accepted: @@ -86,7 +142,22 @@ async def wait(self): @attr.s(auto_attribs=True) -class TextInputDialog: +class TextInputDialog(AbstractDialog): + """Manage a dialog for inputting an integer from the user. + + Attributes: + title: The title of the dialog. + label: The label for the input widget. + parent: The parent widget for the dialog. + + dialog: The actual dialog widget instance. + edit_widget: The line edit that the user will enter the input into. + ok_button: The entry confirmation button. + cancel_button: The input cancellation button. + result: The result of parsing the user input. + shown: The signal emitted when the dialog is shown. + finished: The signal emitted when the dialog is finished. + """ title: typing.Optional[str] = None label: typing.Optional[str] = None parent: typing.Optional[QtCore.QObject] = None @@ -95,6 +166,7 @@ class TextInputDialog: accept_button: typing.Optional[QtWidgets.QPushButton] = None reject_button: typing.Optional[QtWidgets.QPushButton] = None line_edit: typing.Optional[QtWidgets.QLineEdit] = None + result: typing.Optional[str] = None shown = qtrio._qt.Signal(QtWidgets.QInputDialog) @@ -107,9 +179,9 @@ def build( label: typing.Optional[str] = None, parent: typing.Optional[QtCore.QObject] = None, ) -> "TextInputDialog": - return cls(title=title, label=label, parent=parent) + return TextInputDialog(title=title, label=label, parent=parent) - def setup(self): + def _setup(self) -> None: self.result = None self.dialog = QtWidgets.QInputDialog(parent=self.parent) @@ -122,7 +194,7 @@ def setup(self): self.dialog.finished.connect(self.finished) self.dialog.show() - buttons = dialog_button_box_buttons_by_role(dialog=self.dialog) + buttons = _dialog_button_box_buttons_by_role(dialog=self.dialog) self.accept_button = buttons[QtWidgets.QDialogButtonBox.AcceptRole] self.reject_button = buttons[QtWidgets.QDialogButtonBox.RejectRole] @@ -130,7 +202,7 @@ def setup(self): self.shown.emit(self.dialog) - def teardown(self): + def _teardown(self) -> None: if self.dialog is not None: self.dialog.close() self.dialog.finished.disconnect(self.finished) @@ -138,34 +210,29 @@ def teardown(self): self.accept_button = None self.reject_button = None - async def wait(self): - with manage(dialog=self) as finished_event: + async def wait(self) -> str: + with _manage(dialog=self) as finished_event: + if self.dialog is None: + raise qtrio.InternalError("Dialog not assigned while it is being managed.") + await finished_event.wait() - result = self.dialog.result() + dialog_result = self.dialog.result() - if result == QtWidgets.QDialog.Rejected: + if dialog_result == QtWidgets.QDialog.Rejected: raise qtrio.UserCancelledError() - self.result = self.dialog.textValue() + # TODO: `: str` is a workaround for + # https://github.com/spyder-ide/qtpy/pull/217 + text_result: str = self.dialog.textValue() - return self.result + self.result = text_result - -def dialog_button_box_buttons_by_role( - dialog: QtWidgets.QDialog, -) -> typing.Mapping[QtWidgets.QDialogButtonBox.ButtonRole, QtWidgets.QAbstractButton]: - hits = dialog.findChildren(QtWidgets.QDialogButtonBox) - - if len(hits) == 0: - return {} - - [button_box] = hits - return {button_box.buttonRole(button): button for button in button_box.buttons()} + return text_result @attr.s(auto_attribs=True) -class FileDialog: +class FileDialog(AbstractDialog): file_mode: QtWidgets.QFileDialog.FileMode accept_mode: QtWidgets.QFileDialog.AcceptMode dialog: typing.Optional[QtWidgets.QFileDialog] = None @@ -188,7 +255,7 @@ def build( default_file: typing.Optional[trio.Path] = None, options: QtWidgets.QFileDialog.Options = QtWidgets.QFileDialog.Options(), ) -> "FileDialog": - return cls( + return FileDialog( parent=parent, default_directory=default_directory, default_file=default_file, @@ -197,7 +264,7 @@ def build( accept_mode=QtWidgets.QFileDialog.AcceptSave, ) - def setup(self): + def _setup(self) -> None: self.result = None extras = {} @@ -213,6 +280,7 @@ def setup(self): self.dialog = QtWidgets.QFileDialog( parent=self.parent, options=options, **extras ) + print('-------', self.dialog) if self.default_file is not None: self.dialog.selectFile(os.fspath(self.default_file)) @@ -225,13 +293,13 @@ def setup(self): self.dialog.show() - buttons = dialog_button_box_buttons_by_role(dialog=self.dialog) + buttons = _dialog_button_box_buttons_by_role(dialog=self.dialog) self.accept_button = buttons.get(QtWidgets.QDialogButtonBox.AcceptRole) self.reject_button = buttons.get(QtWidgets.QDialogButtonBox.RejectRole) self.shown.emit(self.dialog) - def teardown(self): + def _teardown(self) -> None: if self.dialog is not None: self.dialog.close() self.dialog.finished.disconnect(self.finished) @@ -239,8 +307,11 @@ def teardown(self): self.accept_button = None self.reject_button = None - async def wait(self): - with manage(dialog=self) as finished_event: + async def wait(self) -> trio.Path: + with _manage(dialog=self) as finished_event: + if self.dialog is None: + raise qtrio.InternalError("Dialog not assigned while it is being managed.") + await finished_event.wait() if self.dialog.result() != QtWidgets.QDialog.Accepted: raise qtrio.UserCancelledError() @@ -252,7 +323,7 @@ async def wait(self): @attr.s(auto_attribs=True) -class MessageBox: +class MessageBox(AbstractDialog): icon: QtWidgets.QMessageBox.Icon title: str text: str @@ -275,10 +346,10 @@ def build_information( icon: QtWidgets.QMessageBox.Icon = QtWidgets.QMessageBox.Information, buttons: QtWidgets.QMessageBox.StandardButtons = QtWidgets.QMessageBox.Ok, parent: typing.Optional[QtCore.QObject] = None, - ): - return cls(icon=icon, title=title, text=text, buttons=buttons, parent=parent) + ) -> "MessageBox": + return MessageBox(icon=icon, title=title, text=text, buttons=buttons, parent=parent) - def setup(self): + def _setup(self) -> None: self.result = None self.dialog = QtWidgets.QMessageBox( @@ -290,19 +361,22 @@ def setup(self): self.dialog.show() - buttons = dialog_button_box_buttons_by_role(dialog=self.dialog) + buttons = _dialog_button_box_buttons_by_role(dialog=self.dialog) self.accept_button = buttons[QtWidgets.QDialogButtonBox.AcceptRole] self.shown.emit(self.dialog) - def teardown(self): + def _teardown(self) -> None: if self.dialog is not None: self.dialog.close() self.dialog = None self.accept_button = None - async def wait(self): - with manage(dialog=self) as finished_event: + async def wait(self) -> None: + with _manage(dialog=self) as finished_event: + if self.dialog is None: + raise qtrio.InternalError("Dialog not assigned while it is being managed.") + await finished_event.wait() result = self.dialog.result() From b093f765451a6e84092fe26bd945e870befcbc36 Mon Sep 17 00:00:00 2001 From: Kyle Altendorf Date: Mon, 10 Aug 2020 14:37:07 -0400 Subject: [PATCH 61/71] what it is right now --- docs/source/dialogs.rst | 25 +++++++ qtrio/_tests/test_dialogs.py | 38 +++++----- qtrio/dialogs.py | 139 ++++++++++++++++++++--------------- 3 files changed, 124 insertions(+), 78 deletions(-) diff --git a/docs/source/dialogs.rst b/docs/source/dialogs.rst index 817b2e3a..b60ac4a0 100644 --- a/docs/source/dialogs.rst +++ b/docs/source/dialogs.rst @@ -1,8 +1,33 @@ Dialogs ======= +Usage Pattern +------------- + +.. autoclass:: qtrio.dialogs.AbstractDialog + :members: + + +Creation Functions +------------------ + +.. autofunction:: qtrio.dialogs.create_integer_dialog +.. autofunction:: qtrio.dialogs.create_text_input_dialog +.. autofunction:: qtrio.dialogs.create_file_save_dialog +.. autofunction:: qtrio.dialogs.create_message_box + + +Classes +------- .. autoclass:: qtrio.dialogs.IntegerDialog + :members: + .. autoclass:: qtrio.dialogs.TextInputDialog + :members: + .. autoclass:: qtrio.dialogs.FileDialog + :members: + .. autoclass:: qtrio.dialogs.MessageBox + :members: diff --git a/qtrio/_tests/test_dialogs.py b/qtrio/_tests/test_dialogs.py index b50b41c0..8216bafa 100644 --- a/qtrio/_tests/test_dialogs.py +++ b/qtrio/_tests/test_dialogs.py @@ -13,13 +13,17 @@ import qtrio._qt +def create_message_box_without_arguments(): + return qtrio.dialogs.create_message_box(title="", text="") + + @pytest.fixture( name="builder", params=[ - qtrio.dialogs.IntegerDialog.build, - qtrio.dialogs.TextInputDialog.build, - qtrio.dialogs.FileDialog.build, - lambda: qtrio.dialogs.MessageBox.build_information(title="", text=""), + qtrio.dialogs.create_integer_dialog, + qtrio.dialogs.create_text_input_dialog, + qtrio.dialogs.create_file_save_dialog, + create_message_box_without_arguments, ], ) def builder_fixture(request): @@ -28,7 +32,7 @@ def builder_fixture(request): @qtrio.host async def test_get_integer_gets_value(request, qtbot): - dialog = qtrio.dialogs.IntegerDialog.build() + dialog = qtrio.dialogs.create_integer_dialog() async def user(task_status): async with qtrio._core.wait_signal_context(dialog.shown): @@ -49,7 +53,7 @@ async def user(task_status): @qtrio.host async def test_get_integer_raises_cancel_when_canceled(request, qtbot): - dialog = qtrio.dialogs.IntegerDialog.build() + dialog = qtrio.dialogs.create_integer_dialog() async def user(task_status): async with qtrio._core.wait_signal_context(dialog.shown): @@ -67,7 +71,7 @@ async def user(task_status): @qtrio.host async def test_get_integer_raises_for_invalid_input(request, qtbot): - dialog = qtrio.dialogs.IntegerDialog.build() + dialog = qtrio.dialogs.create_integer_dialog() async def user(task_status): async with qtrio._core.wait_signal_context(dialog.shown): @@ -85,14 +89,14 @@ async def user(task_status): def test_unused_dialog_teardown_ok(builder): dialog = builder() - dialog._teardown() + dialog.teardown() @qtrio.host(timeout=10) async def test_file_save(request, qtbot, tmp_path): path_to_select = trio.Path(tmp_path) / "something.new" - dialog = qtrio.dialogs.FileDialog.build( + dialog = qtrio.dialogs.create_file_save_dialog( default_directory=path_to_select.parent, default_file=path_to_select, ) @@ -120,7 +124,7 @@ async def user(task_status): async def test_file_save_no_defaults(request, qtbot, tmp_path): path_to_select = trio.Path(tmp_path) / "another.thing" - dialog = qtrio.dialogs.FileDialog.build() + dialog = qtrio.dialogs.create_file_save_dialog() async def user(task_status): async with qtrio._core.wait_signal_context(dialog.shown): @@ -147,7 +151,7 @@ async def user(task_status): @qtrio.host(timeout=10) async def test_file_save_cancelled(request, qtbot, tmp_path): - dialog = qtrio.dialogs.FileDialog.build() + dialog = qtrio.dialogs.create_file_save_dialog() async def user(task_status): async with qtrio._core.wait_signal_context(dialog.shown): @@ -173,7 +177,7 @@ async def test_information_message_box(request, qtbot): text = "Consider yourself informed." queried_text = None - dialog = qtrio.dialogs.MessageBox.build_information( + dialog = qtrio.dialogs.create_message_box( title="Information", text=text, icon=QtWidgets.QMessageBox.Information, ) @@ -198,7 +202,7 @@ async def user(task_status): @qtrio.host async def test_information_message_box_cancel(request, qtbot): - dialog = qtrio.dialogs.MessageBox.build_information( + dialog = qtrio.dialogs.create_message_box( title="", text="", icon=QtWidgets.QMessageBox.Information, @@ -222,7 +226,7 @@ async def user(task_status): @qtrio.host async def test_text_input_dialog(request, qtbot): - dialog = qtrio.dialogs.TextInputDialog.build() + dialog = qtrio.dialogs.create_text_input_dialog() entered_text = "etcetera" @@ -246,7 +250,7 @@ async def user(task_status): def test_text_input_dialog_with_title(): title_string = "abc123" - dialog = qtrio.dialogs.TextInputDialog.build(title=title_string) + dialog = qtrio.dialogs.create_text_input_dialog(title=title_string) with qtrio.dialogs._manage(dialog=dialog): assert dialog.dialog is not None @@ -255,7 +259,7 @@ def test_text_input_dialog_with_title(): def test_text_input_dialog_with_label(): label_string = "lmno789" - dialog = qtrio.dialogs.TextInputDialog.build(label=label_string) + dialog = qtrio.dialogs.create_text_input_dialog(label=label_string) with qtrio.dialogs._manage(dialog=dialog): assert dialog.dialog is not None @@ -265,7 +269,7 @@ def test_text_input_dialog_with_label(): @qtrio.host async def test_text_input_dialog_cancel(request, qtbot): - dialog = qtrio.dialogs.TextInputDialog.build() + dialog = qtrio.dialogs.create_text_input_dialog() async def user(task_status): async with qtrio._core.wait_signal_context(dialog.shown): diff --git a/qtrio/dialogs.py b/qtrio/dialogs.py index 51ccbcf0..77e781e9 100644 --- a/qtrio/dialogs.py +++ b/qtrio/dialogs.py @@ -14,19 +14,30 @@ class AbstractDialog: + """The common interface used for working with QTrio dialogs. To minimize the + involvement of inheritance this class does not use :class:`abc.ABC` or + :class:`abc.ABCMeta`. Also note that ``@property`` is used for instance attributes. + While only using :func:`abc.abstractmethod` means there is no runtime check made, + ``mypy`` is able to check for instantiation of abstract classes. + """ @property @abc.abstractmethod def finished(self) -> qtrio._qt.Signal: + """The signal emitted when the dialog is finished.""" # should really be QtWidgets.QDialog.DialogCode not int - pass @abc.abstractmethod - def _setup(self) -> None: - ... + def setup(self) -> None: + """Setup and show the dialog.""" + + @abc.abstractmethod + def teardown(self) -> None: + """Teardown and hide the dialog.""" @abc.abstractmethod - def _teardown(self) -> None: - ... + async def wait(self) -> object: + """Show the dialog, wait for the user interction, and return the result. Raises + :class:`qtrio.UserCancelledError` if the user cancels the dialog.""" @contextlib.contextmanager @@ -38,10 +49,10 @@ def slot(*args: object, **kwargs: object) -> None: with qtrio._qt.connection(signal=dialog.finished, slot=slot): try: - dialog._setup() + dialog.setup() yield finished_event finally: - dialog._teardown() + dialog.teardown() def _dialog_button_box_buttons_by_role( @@ -70,6 +81,7 @@ class IntegerDialog(AbstractDialog): shown: The signal emitted when the dialog is shown. finished: The signal emitted when the dialog is finished. """ + parent: QtWidgets.QWidget dialog: typing.Optional[QtWidgets.QInputDialog] = None @@ -82,11 +94,7 @@ class IntegerDialog(AbstractDialog): shown = qtrio._qt.Signal(QtWidgets.QInputDialog) finished = qtrio._qt.Signal(int) # QtWidgets.QDialog.DialogCode - @classmethod - def build(cls, parent: QtCore.QObject = None,) -> "IntegerDialog": - return IntegerDialog(parent=parent) - - def _setup(self) -> None: + def setup(self) -> None: """Create the actual dialog widget and perform related setup activities including showing the widget and emitting :attr:`qtrio.dialogs.IntegerDialog.shown` when done. @@ -108,7 +116,7 @@ def _setup(self) -> None: self.shown.emit(self.dialog) - def _teardown(self) -> None: + def teardown(self) -> None: """Teardown the dialog, signal and slot connections, widget references, etc.""" if self.dialog is not None: self.dialog.close() @@ -126,7 +134,9 @@ async def wait(self) -> int: """ with _manage(dialog=self) as finished_event: if self.dialog is None: - raise qtrio.InternalError("Dialog not assigned while it is being managed.") + raise qtrio.InternalError( + "Dialog not assigned while it is being managed." + ) await finished_event.wait() @@ -141,6 +151,10 @@ async def wait(self) -> int: return self.result +def create_integer_dialog(parent: QtCore.QObject = None,) -> IntegerDialog: + return IntegerDialog(parent=parent) + + @attr.s(auto_attribs=True) class TextInputDialog(AbstractDialog): """Manage a dialog for inputting an integer from the user. @@ -158,6 +172,7 @@ class TextInputDialog(AbstractDialog): shown: The signal emitted when the dialog is shown. finished: The signal emitted when the dialog is finished. """ + title: typing.Optional[str] = None label: typing.Optional[str] = None parent: typing.Optional[QtCore.QObject] = None @@ -172,16 +187,7 @@ class TextInputDialog(AbstractDialog): shown = qtrio._qt.Signal(QtWidgets.QInputDialog) finished = qtrio._qt.Signal(int) # QtWidgets.QDialog.DialogCode - @classmethod - def build( - cls, - title: typing.Optional[str] = None, - label: typing.Optional[str] = None, - parent: typing.Optional[QtCore.QObject] = None, - ) -> "TextInputDialog": - return TextInputDialog(title=title, label=label, parent=parent) - - def _setup(self) -> None: + def setup(self) -> None: self.result = None self.dialog = QtWidgets.QInputDialog(parent=self.parent) @@ -202,7 +208,7 @@ def _setup(self) -> None: self.shown.emit(self.dialog) - def _teardown(self) -> None: + def teardown(self) -> None: if self.dialog is not None: self.dialog.close() self.dialog.finished.disconnect(self.finished) @@ -213,7 +219,9 @@ def _teardown(self) -> None: async def wait(self) -> str: with _manage(dialog=self) as finished_event: if self.dialog is None: - raise qtrio.InternalError("Dialog not assigned while it is being managed.") + raise qtrio.InternalError( + "Dialog not assigned while it is being managed." + ) await finished_event.wait() @@ -231,6 +239,14 @@ async def wait(self) -> str: return text_result +def create_text_input_dialog( + title: typing.Optional[str] = None, + label: typing.Optional[str] = None, + parent: typing.Optional[QtCore.QObject] = None, +) -> TextInputDialog: + return TextInputDialog(title=title, label=label, parent=parent) + + @attr.s(auto_attribs=True) class FileDialog(AbstractDialog): file_mode: QtWidgets.QFileDialog.FileMode @@ -247,24 +263,7 @@ class FileDialog(AbstractDialog): shown = qtrio._qt.Signal(QtWidgets.QFileDialog) finished = qtrio._qt.Signal(int) # QtWidgets.QDialog.DialogCode - @classmethod - def build( - cls, - parent: typing.Optional[QtCore.QObject] = None, - default_directory: typing.Optional[trio.Path] = None, - default_file: typing.Optional[trio.Path] = None, - options: QtWidgets.QFileDialog.Options = QtWidgets.QFileDialog.Options(), - ) -> "FileDialog": - return FileDialog( - parent=parent, - default_directory=default_directory, - default_file=default_file, - options=options, - file_mode=QtWidgets.QFileDialog.AnyFile, - accept_mode=QtWidgets.QFileDialog.AcceptSave, - ) - - def _setup(self) -> None: + def setup(self) -> None: self.result = None extras = {} @@ -280,7 +279,6 @@ def _setup(self) -> None: self.dialog = QtWidgets.QFileDialog( parent=self.parent, options=options, **extras ) - print('-------', self.dialog) if self.default_file is not None: self.dialog.selectFile(os.fspath(self.default_file)) @@ -299,7 +297,7 @@ def _setup(self) -> None: self.shown.emit(self.dialog) - def _teardown(self) -> None: + def teardown(self) -> None: if self.dialog is not None: self.dialog.close() self.dialog.finished.disconnect(self.finished) @@ -310,7 +308,9 @@ def _teardown(self) -> None: async def wait(self) -> trio.Path: with _manage(dialog=self) as finished_event: if self.dialog is None: - raise qtrio.InternalError("Dialog not assigned while it is being managed.") + raise qtrio.InternalError( + "Dialog not assigned while it is being managed." + ) await finished_event.wait() if self.dialog.result() != QtWidgets.QDialog.Accepted: @@ -322,6 +322,22 @@ async def wait(self) -> trio.Path: return self.result +def create_file_save_dialog( + parent: typing.Optional[QtCore.QObject] = None, + default_directory: typing.Optional[trio.Path] = None, + default_file: typing.Optional[trio.Path] = None, + options: QtWidgets.QFileDialog.Options = QtWidgets.QFileDialog.Options(), +) -> FileDialog: + return FileDialog( + parent=parent, + default_directory=default_directory, + default_file=default_file, + options=options, + file_mode=QtWidgets.QFileDialog.AnyFile, + accept_mode=QtWidgets.QFileDialog.AcceptSave, + ) + + @attr.s(auto_attribs=True) class MessageBox(AbstractDialog): icon: QtWidgets.QMessageBox.Icon @@ -338,18 +354,7 @@ class MessageBox(AbstractDialog): shown = qtrio._qt.Signal(QtWidgets.QMessageBox) finished = qtrio._qt.Signal(int) # QtWidgets.QDialog.DialogCode - @classmethod - def build_information( - cls, - title: str, - text: str, - icon: QtWidgets.QMessageBox.Icon = QtWidgets.QMessageBox.Information, - buttons: QtWidgets.QMessageBox.StandardButtons = QtWidgets.QMessageBox.Ok, - parent: typing.Optional[QtCore.QObject] = None, - ) -> "MessageBox": - return MessageBox(icon=icon, title=title, text=text, buttons=buttons, parent=parent) - - def _setup(self) -> None: + def setup(self) -> None: self.result = None self.dialog = QtWidgets.QMessageBox( @@ -366,7 +371,7 @@ def _setup(self) -> None: self.shown.emit(self.dialog) - def _teardown(self) -> None: + def teardown(self) -> None: if self.dialog is not None: self.dialog.close() self.dialog = None @@ -375,7 +380,9 @@ def _teardown(self) -> None: async def wait(self) -> None: with _manage(dialog=self) as finished_event: if self.dialog is None: - raise qtrio.InternalError("Dialog not assigned while it is being managed.") + raise qtrio.InternalError( + "Dialog not assigned while it is being managed." + ) await finished_event.wait() @@ -383,3 +390,13 @@ async def wait(self) -> None: if result == QtWidgets.QDialog.Rejected: raise qtrio.UserCancelledError() + + +def create_message_box( + title: str, + text: str, + icon: QtWidgets.QMessageBox.Icon = QtWidgets.QMessageBox.Information, + buttons: QtWidgets.QMessageBox.StandardButtons = QtWidgets.QMessageBox.Ok, + parent: typing.Optional[QtCore.QObject] = None, +) -> MessageBox: + return MessageBox(icon=icon, title=title, text=text, buttons=buttons, parent=parent) From b7b95140addbeeb0a3cbf9fe5e0e23e763812c05 Mon Sep 17 00:00:00 2001 From: Kyle Altendorf Date: Wed, 12 Aug 2020 12:02:03 -0400 Subject: [PATCH 62/71] step --- docs/source/dialogs.rst | 13 ++- docs/source/exceptions.rst | 2 + qtrio/dialogs.py | 158 ++++++++++++++++++++++++++++--------- 3 files changed, 130 insertions(+), 43 deletions(-) diff --git a/docs/source/dialogs.rst b/docs/source/dialogs.rst index b60ac4a0..47187266 100644 --- a/docs/source/dialogs.rst +++ b/docs/source/dialogs.rst @@ -4,10 +4,6 @@ Dialogs Usage Pattern ------------- -.. autoclass:: qtrio.dialogs.AbstractDialog - :members: - - Creation Functions ------------------ @@ -31,3 +27,12 @@ Classes .. autoclass:: qtrio.dialogs.MessageBox :members: + + +Protocol +-------- + +.. autoclass:: qtrio.dialogs.DialogProtocol + :members: + +.. autofunction:: qtrio.dialogs.check_dialog_protocol diff --git a/docs/source/exceptions.rst b/docs/source/exceptions.rst index 75ebacc0..49b410f4 100644 --- a/docs/source/exceptions.rst +++ b/docs/source/exceptions.rst @@ -9,3 +9,5 @@ Exceptions .. autoclass:: qtrio.EventTypeAlreadyRegisteredError .. autoclass:: qtrio.ReturnCodeError .. autoclass:: qtrio.InternalError +.. autoclass:: qtrio.UserCancelledError +.. autoclass:: qtrio.InvalidInputError diff --git a/qtrio/dialogs.py b/qtrio/dialogs.py index 77e781e9..28862ec4 100644 --- a/qtrio/dialogs.py +++ b/qtrio/dialogs.py @@ -13,38 +13,61 @@ import qtrio._qt -class AbstractDialog: - """The common interface used for working with QTrio dialogs. To minimize the - involvement of inheritance this class does not use :class:`abc.ABC` or - :class:`abc.ABCMeta`. Also note that ``@property`` is used for instance attributes. - While only using :func:`abc.abstractmethod` means there is no runtime check made, - ``mypy`` is able to check for instantiation of abstract classes. +class DialogProtocol(typing.Protocol): + """The common interface used for working with QTrio dialogs. To check that a class + implements this protocol, decorate it with + :func:`qtrio.dialogs.check_dialog_protocol`. + + Attributes: + shown: The signal to be emitted when the dialog is shown. + finished: The signal to be emitted when the dialog is finished. """ - @property - @abc.abstractmethod - def finished(self) -> qtrio._qt.Signal: - """The signal emitted when the dialog is finished.""" - # should really be QtWidgets.QDialog.DialogCode not int - @abc.abstractmethod + shown: qtrio._qt.Signal + finished: qtrio._qt.Signal + def setup(self) -> None: - """Setup and show the dialog.""" + """Setup and show the dialog. Emit :attr:`qtrio.dialogs.DialogProtocol.shown` + when done. + """ - @abc.abstractmethod def teardown(self) -> None: - """Teardown and hide the dialog.""" + """Hide and teardown the dialog.""" - @abc.abstractmethod async def wait(self) -> object: - """Show the dialog, wait for the user interction, and return the result. Raises - :class:`qtrio.UserCancelledError` if the user cancels the dialog.""" + """Show the dialog, wait for the user interaction, and return the result. + Raises :class:`qtrio.UserCancelledError` if the user cancels the dialog. + """ + + +DialogProtocolT = typing.TypeVar("DialogProtocolT", bound=DialogProtocol) + + +def check_dialog_protocol( + cls: typing.Type[DialogProtocolT], +) -> typing.Type[DialogProtocolT]: + """Decorate a class with this to verify it implements the :class:`DialogProtocol` + when a type hint checker such as mypy is run against the code. At runtime the + passed class is cleanly returned. + + Arguments: + cls: The class to trigger a protocol check against. + """ + return cls @contextlib.contextmanager -def _manage(dialog: AbstractDialog) -> typing.Generator[trio.Event, None, None]: +def _manage(dialog: DialogProtocol) -> typing.ContextManager[trio.Event]: + """Manage the setup and teardown of a dialog including yielding a + :class:`trio.Event` that is set when the dialog is finished. + + Arguments: + dialog: The dialog to be managed. + """ finished_event = trio.Event() def slot(*args: object, **kwargs: object) -> None: + """Accept and ignore all arguments, then set the event.""" finished_event.set() with qtrio._qt.connection(signal=dialog.finished, slot=slot): @@ -58,6 +81,8 @@ def slot(*args: object, **kwargs: object) -> None: def _dialog_button_box_buttons_by_role( dialog: QtWidgets.QDialog, ) -> typing.Mapping[QtWidgets.QDialogButtonBox.ButtonRole, QtWidgets.QAbstractButton]: + """Create mapping from button roles to their corresponding buttons.""" + hits = dialog.findChildren(QtWidgets.QDialogButtonBox) if len(hits) == 0: @@ -67,22 +92,26 @@ def _dialog_button_box_buttons_by_role( return {button_box.buttonRole(button): button for button in button_box.buttons()} +@check_dialog_protocol @attr.s(auto_attribs=True) -class IntegerDialog(AbstractDialog): +class IntegerDialog: """Manage a dialog for inputting an integer from the user. Attributes: parent: The parent widget for the dialog. + dialog: The actual dialog widget instance. edit_widget: The line edit that the user will enter the input into. ok_button: The entry confirmation button. cancel_button: The input cancellation button. + result: The result of parsing the user input. - shown: The signal emitted when the dialog is shown. - finished: The signal emitted when the dialog is finished. + + shown: See :attr:`qtrio.dialogs.DialogProtocol.shown`. + finished: See :attr:`qtrio.dialogs.DialogProtocol.finished`. """ - parent: QtWidgets.QWidget + parent: QtWidgets.QWidget = None dialog: typing.Optional[QtWidgets.QInputDialog] = None edit_widget: typing.Optional[QtWidgets.QLineEdit] = None @@ -95,10 +124,8 @@ class IntegerDialog(AbstractDialog): finished = qtrio._qt.Signal(int) # QtWidgets.QDialog.DialogCode def setup(self) -> None: - """Create the actual dialog widget and perform related setup activities - including showing the widget and emitting - :attr:`qtrio.dialogs.IntegerDialog.shown` when done. - """ + """See :meth:`qtrio.dialogs.DialogProtocol.setup`.""" + self.result = None self.dialog = QtWidgets.QInputDialog(self.parent) @@ -117,7 +144,8 @@ def setup(self) -> None: self.shown.emit(self.dialog) def teardown(self) -> None: - """Teardown the dialog, signal and slot connections, widget references, etc.""" + """See :meth:`qtrio.dialogs.DialogProtocol.teardown`.""" + if self.dialog is not None: self.dialog.close() self.dialog.finished.disconnect(self.finished) @@ -155,8 +183,9 @@ def create_integer_dialog(parent: QtCore.QObject = None,) -> IntegerDialog: return IntegerDialog(parent=parent) +@check_dialog_protocol @attr.s(auto_attribs=True) -class TextInputDialog(AbstractDialog): +class TextInputDialog: """Manage a dialog for inputting an integer from the user. Attributes: @@ -168,9 +197,11 @@ class TextInputDialog(AbstractDialog): edit_widget: The line edit that the user will enter the input into. ok_button: The entry confirmation button. cancel_button: The input cancellation button. + result: The result of parsing the user input. - shown: The signal emitted when the dialog is shown. - finished: The signal emitted when the dialog is finished. + + shown: See :attr:`qtrio.dialogs.DialogProtocol.shown`. + finished: See :attr:`qtrio.dialogs.DialogProtocol.finished`. """ title: typing.Optional[str] = None @@ -188,6 +219,8 @@ class TextInputDialog(AbstractDialog): finished = qtrio._qt.Signal(int) # QtWidgets.QDialog.DialogCode def setup(self) -> None: + """See :meth:`qtrio.dialogs.DialogProtocol.setup`.""" + self.result = None self.dialog = QtWidgets.QInputDialog(parent=self.parent) @@ -209,6 +242,8 @@ def setup(self) -> None: self.shown.emit(self.dialog) def teardown(self) -> None: + """See :meth:`qtrio.dialogs.DialogProtocol.teardown`.""" + if self.dialog is not None: self.dialog.close() self.dialog.finished.disconnect(self.finished) @@ -217,6 +252,8 @@ def teardown(self) -> None: self.reject_button = None async def wait(self) -> str: + """See :meth:`qtrio.dialogs.DialogProtocol.teardown`.""" + with _manage(dialog=self) as finished_event: if self.dialog is None: raise qtrio.InternalError( @@ -247,23 +284,48 @@ def create_text_input_dialog( return TextInputDialog(title=title, label=label, parent=parent) +@check_dialog_protocol @attr.s(auto_attribs=True) -class FileDialog(AbstractDialog): +class FileDialog: + """Manage a dialog for inputting an integer from the user. + + Attributes: + file_mode: + accept_mode: + default_directory: + default_file: + options: + parent: The parent widget for the dialog. + + dialog: The actual dialog widget instance. + accept_button: The confirmation button. + reject_button: The cancellation button. + + result: The selected path. + + shown: See :attr:`qtrio.dialogs.DialogProtocol.shown`. + finished: See :attr:`qtrio.dialogs.DialogProtocol.finished`. + """ + file_mode: QtWidgets.QFileDialog.FileMode accept_mode: QtWidgets.QFileDialog.AcceptMode - dialog: typing.Optional[QtWidgets.QFileDialog] = None - parent: typing.Optional[QtCore.QObject] = None default_directory: typing.Optional[trio.Path] = None default_file: typing.Optional[trio.Path] = None - options: QtWidgets.QFileDialog.Options = QtWidgets.QFileDialog.Options() + options: QtWidgets.QFileDialog.Option = QtWidgets.QFileDialog.Option() + parent: typing.Optional[QtCore.QObject] = None + + dialog: typing.Optional[QtWidgets.QFileDialog] = None accept_button: typing.Optional[QtWidgets.QPushButton] = None reject_button: typing.Optional[QtWidgets.QPushButton] = None + result: typing.Optional[trio.Path] = None shown = qtrio._qt.Signal(QtWidgets.QFileDialog) finished = qtrio._qt.Signal(int) # QtWidgets.QDialog.DialogCode def setup(self) -> None: + """See :meth:`qtrio.dialogs.DialogProtocol.setup`.""" + self.result = None extras = {} @@ -298,6 +360,8 @@ def setup(self) -> None: self.shown.emit(self.dialog) def teardown(self) -> None: + """See :meth:`qtrio.dialogs.DialogProtocol.teardown`.""" + if self.dialog is not None: self.dialog.close() self.dialog.finished.disconnect(self.finished) @@ -306,6 +370,8 @@ def teardown(self) -> None: self.reject_button = None async def wait(self) -> trio.Path: + """See :meth:`qtrio.dialogs.DialogProtocol.teardown`.""" + with _manage(dialog=self) as finished_event: if self.dialog is None: raise qtrio.InternalError( @@ -326,8 +392,15 @@ def create_file_save_dialog( parent: typing.Optional[QtCore.QObject] = None, default_directory: typing.Optional[trio.Path] = None, default_file: typing.Optional[trio.Path] = None, - options: QtWidgets.QFileDialog.Options = QtWidgets.QFileDialog.Options(), + options: QtWidgets.QFileDialog.Option = QtWidgets.QFileDialog.Option(), ) -> FileDialog: + """ + Arguments: + parent: + default_directory: + default_file: + options: + """ return FileDialog( parent=parent, default_directory=default_directory, @@ -338,23 +411,26 @@ def create_file_save_dialog( ) +@check_dialog_protocol @attr.s(auto_attribs=True) -class MessageBox(AbstractDialog): - icon: QtWidgets.QMessageBox.Icon +class MessageBox: title: str text: str + icon: QtWidgets.QMessageBox.Icon buttons: QtWidgets.QMessageBox.StandardButtons - parent: typing.Optional[QtCore.QObject] = None dialog: typing.Optional[QtWidgets.QMessageBox] = None accept_button: typing.Optional[QtWidgets.QPushButton] = None + result: typing.Optional[trio.Path] = None shown = qtrio._qt.Signal(QtWidgets.QMessageBox) finished = qtrio._qt.Signal(int) # QtWidgets.QDialog.DialogCode def setup(self) -> None: + """See :meth:`qtrio.dialogs.DialogProtocol.setup`.""" + self.result = None self.dialog = QtWidgets.QMessageBox( @@ -372,12 +448,16 @@ def setup(self) -> None: self.shown.emit(self.dialog) def teardown(self) -> None: + """See :meth:`qtrio.dialogs.DialogProtocol.teardown`.""" + if self.dialog is not None: self.dialog.close() self.dialog = None self.accept_button = None async def wait(self) -> None: + """See :meth:`qtrio.dialogs.DialogProtocol.teardown`.""" + with _manage(dialog=self) as finished_event: if self.dialog is None: raise qtrio.InternalError( From 3c2fe4bc169a82a50e387553384e6b334c06711a Mon Sep 17 00:00:00 2001 From: Kyle Altendorf Date: Fri, 21 Aug 2020 15:55:40 -0400 Subject: [PATCH 63/71] more --- docs/source/conf.py | 5 ++ docs/source/core.rst | 5 ++ docs/source/exceptions.rst | 3 - qtrio/__init__.py | 1 + qtrio/_exceptions.py | 14 +-- qtrio/_qt.py | 14 +-- qtrio/_tests/test_qt.py | 10 +-- qtrio/dialogs.py | 171 +++++++++++++++++++------------------ 8 files changed, 119 insertions(+), 104 deletions(-) diff --git a/docs/source/conf.py b/docs/source/conf.py index 10940574..07d84d03 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -39,8 +39,13 @@ ("py:class", "qtrio._core.EmissionsNursery"), ("py:class", "qtrio._core.Outcomes"), ("py:class", "qtrio._core.Reenter"), + ("py:class", "qtrio._qt.Signal"), # https://github.com/Czaki/sphinx-qt-documentation/issues/10 ("py:class", ""), + ("py:class", ""), + ("py:class", ""), + ("py:class", ""), + ("py:class", ""), # https://github.com/sphinx-doc/sphinx/issues/8136 ("py:class", "typing.AbstractAsyncContextManager"), ] diff --git a/docs/source/core.rst b/docs/source/core.rst index 6c9a330f..8105866f 100644 --- a/docs/source/core.rst +++ b/docs/source/core.rst @@ -28,6 +28,11 @@ their errors on the floor they will be run inside a nursery. .. autofunction:: qtrio.open_emissions_nursery .. autoclass:: qtrio.EmissionsNursery +Helpers +------- + +.. autoclass:: qtrio.Signal + Reentry Events -------------- diff --git a/docs/source/exceptions.rst b/docs/source/exceptions.rst index d95d933d..49b410f4 100644 --- a/docs/source/exceptions.rst +++ b/docs/source/exceptions.rst @@ -8,9 +8,6 @@ Exceptions .. autoclass:: qtrio.RequestedEventTypeUnavailableError .. autoclass:: qtrio.EventTypeAlreadyRegisteredError .. autoclass:: qtrio.ReturnCodeError -<<<<<<< HEAD .. autoclass:: qtrio.InternalError .. autoclass:: qtrio.UserCancelledError .. autoclass:: qtrio.InvalidInputError -======= ->>>>>>> master diff --git a/qtrio/__init__.py b/qtrio/__init__.py index 18dc73c7..ee76a523 100644 --- a/qtrio/__init__.py +++ b/qtrio/__init__.py @@ -31,4 +31,5 @@ Reenter, ) +from ._qt import Signal from ._pytest import host diff --git a/qtrio/_exceptions.py b/qtrio/_exceptions.py index e8cc3843..01749b0c 100644 --- a/qtrio/_exceptions.py +++ b/qtrio/_exceptions.py @@ -85,6 +85,13 @@ def __eq__(self, other: object) -> bool: return self.args == other.args +class InternalError(QTrioException): + """Raised when an internal state is inconsistent.""" + + # https://github.com/sphinx-doc/sphinx/issues/7493 + __module__ = "qtrio" + + class UserCancelledError(QTrioException): """Raised when a user requested cancellation of an operation.""" @@ -104,10 +111,3 @@ class InvalidInputError(QTrioException): # https://github.com/sphinx-doc/sphinx/issues/7493 __module__ = "qtrio" - - -class InternalError(QTrioException): - """Raised when an internal state is inconsistent.""" - - # https://github.com/sphinx-doc/sphinx/issues/7493 - __module__ = "qtrio" diff --git a/qtrio/_qt.py b/qtrio/_qt.py index b3f80622..ad06eb87 100644 --- a/qtrio/_qt.py +++ b/qtrio/_qt.py @@ -22,7 +22,7 @@ class Signal: object. """ - attribute_name: str = "" + _attribute_name: typing.ClassVar[str] = "" def __init__(self, *args: object, **kwargs: object) -> None: class _SignalQObject(QtCore.QObject): @@ -39,7 +39,11 @@ def __get__(self, instance: object, owner: object) -> qtrio._util.SignalInstance return o.signal def object(self, instance: object) -> QtCore.QObject: - """Get the :class:`QtCore.QObject` that hosts the real signal. + """Get the :class:`QtCore.QObject` that hosts the real signal. This can be + called such as ``type(instance).signal_name.object(instance)``. Yes this is + non-obvious but you have to do something special to get around the + :ref:`descriptor protocol ` so you can get at this method + instead of just having the underlying :class:`QtCore.SignalInstance`. Arguments: instance: The object on which this descriptor instance is hosted. @@ -47,11 +51,11 @@ def object(self, instance: object) -> QtCore.QObject: Returns: The signal-hosting :class:`QtCore.QObject`. """ - d = getattr(instance, self.attribute_name, None) + d = getattr(instance, self._attribute_name, None) if d is None: d = {} - setattr(instance, self.attribute_name, d) + setattr(instance, self._attribute_name, d) o = d.get(self.object_cls) if o is None: @@ -61,7 +65,7 @@ def object(self, instance: object) -> QtCore.QObject: return o -Signal.attribute_name = qtrio._python.identifier_path(Signal) +Signal._attribute_name = qtrio._python.identifier_path(Signal) @contextlib.contextmanager diff --git a/qtrio/_tests/test_qt.py b/qtrio/_tests/test_qt.py index c02398d8..dd5f1121 100644 --- a/qtrio/_tests/test_qt.py +++ b/qtrio/_tests/test_qt.py @@ -8,7 +8,7 @@ def test_signal_emits(qtbot): """qtrio._core.Signal emits.""" class NotQObject: - signal = qtrio._qt.Signal() + signal = qtrio.Signal() instance = NotQObject() @@ -20,7 +20,7 @@ def test_signal_emits_value(qtbot): """qtrio._core.Signal emits a value.""" class NotQObject: - signal = qtrio._qt.Signal(int) + signal = qtrio.Signal(int) result = None @@ -41,16 +41,16 @@ def test_accessing_signal_on_class_results_in_our_signal(): """qtrio._core.Signal instance accessible via class attribute.""" class NotQObject: - signal = qtrio._qt.Signal(int) + signal = qtrio.Signal(int) - assert isinstance(NotQObject.signal, qtrio._qt.Signal) + assert isinstance(NotQObject.signal, qtrio.Signal) def test_our_signal_object_method_returns_qobject(): """qtrio._core.Signal instance provides access to signal-hosting QObject.""" class NotQObject: - signal = qtrio._qt.Signal(int) + signal = qtrio.Signal(int) instance = NotQObject() diff --git a/qtrio/dialogs.py b/qtrio/dialogs.py index 28862ec4..f4724738 100644 --- a/qtrio/dialogs.py +++ b/qtrio/dialogs.py @@ -1,15 +1,13 @@ -import abc import contextlib import os import sys import typing -import async_generator import attr -from qtpy import QtCore from qtpy import QtWidgets import trio +import qtrio import qtrio._qt @@ -17,14 +15,12 @@ class DialogProtocol(typing.Protocol): """The common interface used for working with QTrio dialogs. To check that a class implements this protocol, decorate it with :func:`qtrio.dialogs.check_dialog_protocol`. - - Attributes: - shown: The signal to be emitted when the dialog is shown. - finished: The signal to be emitted when the dialog is finished. """ - shown: qtrio._qt.Signal - finished: qtrio._qt.Signal + shown: qtrio.Signal + """The signal to be emitted when the dialog is shown.""" + finished: qtrio.Signal + """The signal to be emitted when the dialog is finished.""" def setup(self) -> None: """Setup and show the dialog. Emit :attr:`qtrio.dialogs.DialogProtocol.shown` @@ -51,7 +47,7 @@ def check_dialog_protocol( passed class is cleanly returned. Arguments: - cls: The class to trigger a protocol check against. + cls: """ return cls @@ -95,33 +91,27 @@ def _dialog_button_box_buttons_by_role( @check_dialog_protocol @attr.s(auto_attribs=True) class IntegerDialog: - """Manage a dialog for inputting an integer from the user. - - Attributes: - parent: The parent widget for the dialog. - - dialog: The actual dialog widget instance. - edit_widget: The line edit that the user will enter the input into. - ok_button: The entry confirmation button. - cancel_button: The input cancellation button. - - result: The result of parsing the user input. - - shown: See :attr:`qtrio.dialogs.DialogProtocol.shown`. - finished: See :attr:`qtrio.dialogs.DialogProtocol.finished`. - """ - + """Manage a dialog for inputting an integer from the user. Generally instances + should be built via :func:`qtrio.dialogs.create_integer_dialog`.""" parent: QtWidgets.QWidget = None + """The parent widget for the dialog.""" dialog: typing.Optional[QtWidgets.QInputDialog] = None + """The actual dialog widget instance.""" edit_widget: typing.Optional[QtWidgets.QLineEdit] = None + """The line edit that the user will enter the input into.""" ok_button: typing.Optional[QtWidgets.QPushButton] = None + """The entry confirmation button.""" cancel_button: typing.Optional[QtWidgets.QPushButton] = None + """The input cancellation button.""" result: typing.Optional[int] = None + """The result of parsing the user input.""" - shown = qtrio._qt.Signal(QtWidgets.QInputDialog) - finished = qtrio._qt.Signal(int) # QtWidgets.QDialog.DialogCode + shown = qtrio.Signal(QtWidgets.QInputDialog) + """See :attr:`qtrio.dialogs.DialogProtocol.shown`.""" + finished = qtrio.Signal(int) # QtWidgets.QDialog.DialogCode + """See :attr:`qtrio.dialogs.DialogProtocol.finished`.""" def setup(self) -> None: """See :meth:`qtrio.dialogs.DialogProtocol.setup`.""" @@ -156,8 +146,8 @@ def teardown(self) -> None: async def wait(self) -> int: """Setup the dialog, wait for the user input, teardown, and return the user - input. Raises :class:`qtrio.UserCancelledError` if the user cancels the dialog. - Raises :class:`qtrio.InvalidInputError` if the input can't be parsed as an + input. Raises :exc:`qtrio.UserCancelledError` if the user cancels the dialog. + Raises :exc:`qtrio.InvalidInputError` if the input can't be parsed as an integer. """ with _manage(dialog=self) as finished_event: @@ -179,44 +169,39 @@ async def wait(self) -> int: return self.result -def create_integer_dialog(parent: QtCore.QObject = None,) -> IntegerDialog: +def create_integer_dialog(parent: QtWidgets.QWidget = None,) -> IntegerDialog: return IntegerDialog(parent=parent) @check_dialog_protocol @attr.s(auto_attribs=True) class TextInputDialog: - """Manage a dialog for inputting an integer from the user. - - Attributes: - title: The title of the dialog. - label: The label for the input widget. - parent: The parent widget for the dialog. - - dialog: The actual dialog widget instance. - edit_widget: The line edit that the user will enter the input into. - ok_button: The entry confirmation button. - cancel_button: The input cancellation button. - - result: The result of parsing the user input. - - shown: See :attr:`qtrio.dialogs.DialogProtocol.shown`. - finished: See :attr:`qtrio.dialogs.DialogProtocol.finished`. - """ + """Manage a dialog for inputting an integer from the user. Generally instances + should be built via :func:`qtrio.dialogs.create_text_input_dialog`.""" title: typing.Optional[str] = None + """The title of the dialog.""" label: typing.Optional[str] = None - parent: typing.Optional[QtCore.QObject] = None + """The label for the input widget.""" + parent: typing.Optional[QtWidgets.QWidget] = None + """The parent widget for the dialog.""" dialog: typing.Optional[QtWidgets.QInputDialog] = None + """The actual dialog widget instance.""" accept_button: typing.Optional[QtWidgets.QPushButton] = None + """The entry confirmation button.""" reject_button: typing.Optional[QtWidgets.QPushButton] = None + """The input cancellation button.""" line_edit: typing.Optional[QtWidgets.QLineEdit] = None + """The line edit that the user will enter the input into.""" result: typing.Optional[str] = None + """The result of parsing the user input.""" - shown = qtrio._qt.Signal(QtWidgets.QInputDialog) - finished = qtrio._qt.Signal(int) # QtWidgets.QDialog.DialogCode + shown = qtrio.Signal(QtWidgets.QInputDialog) + """See :attr:`qtrio.dialogs.DialogProtocol.shown`.""" + finished = qtrio.Signal(int) # QtWidgets.QDialog.DialogCode + """See :attr:`qtrio.dialogs.DialogProtocol.finished`.""" def setup(self) -> None: """See :meth:`qtrio.dialogs.DialogProtocol.setup`.""" @@ -279,7 +264,7 @@ async def wait(self) -> str: def create_text_input_dialog( title: typing.Optional[str] = None, label: typing.Optional[str] = None, - parent: typing.Optional[QtCore.QObject] = None, + parent: typing.Optional[QtWidgets.QWidget] = None, ) -> TextInputDialog: return TextInputDialog(title=title, label=label, parent=parent) @@ -287,41 +272,38 @@ def create_text_input_dialog( @check_dialog_protocol @attr.s(auto_attribs=True) class FileDialog: - """Manage a dialog for inputting an integer from the user. - - Attributes: - file_mode: - accept_mode: - default_directory: - default_file: - options: - parent: The parent widget for the dialog. - - dialog: The actual dialog widget instance. - accept_button: The confirmation button. - reject_button: The cancellation button. - - result: The selected path. - - shown: See :attr:`qtrio.dialogs.DialogProtocol.shown`. - finished: See :attr:`qtrio.dialogs.DialogProtocol.finished`. - """ + """Manage a dialog for allowing the user to select a file or directory. Generally + instances should be built via :func:`qtrio.dialogs.create_file_save_dialog`.""" file_mode: QtWidgets.QFileDialog.FileMode + """Controls whether the dialog is for picking an existing vs. new file or directory, + etc. + """ accept_mode: QtWidgets.QFileDialog.AcceptMode + """Specify an open vs. a save dialog.""" default_directory: typing.Optional[trio.Path] = None + """The directory to be initially presented in the dialog.""" default_file: typing.Optional[trio.Path] = None + """The file to be initially selected in the dialog.""" options: QtWidgets.QFileDialog.Option = QtWidgets.QFileDialog.Option() - parent: typing.Optional[QtCore.QObject] = None + """Miscellanious options. See the Qt documentation.""" + parent: typing.Optional[QtWidgets.QWidget] = None + """The parent widget for the dialog.""" dialog: typing.Optional[QtWidgets.QFileDialog] = None + """The actual dialog widget instance.""" accept_button: typing.Optional[QtWidgets.QPushButton] = None + """The confirmation button.""" reject_button: typing.Optional[QtWidgets.QPushButton] = None + """The cancellation button.""" result: typing.Optional[trio.Path] = None + """The path selected by the user.""" - shown = qtrio._qt.Signal(QtWidgets.QFileDialog) - finished = qtrio._qt.Signal(int) # QtWidgets.QDialog.DialogCode + shown = qtrio.Signal(QtWidgets.QFileDialog) + """See :attr:`qtrio.dialogs.DialogProtocol.shown`.""" + finished = qtrio.Signal(int) # QtWidgets.QDialog.DialogCode + """See :attr:`qtrio.dialogs.DialogProtocol.finished`.""" def setup(self) -> None: """See :meth:`qtrio.dialogs.DialogProtocol.setup`.""" @@ -389,17 +371,18 @@ async def wait(self) -> trio.Path: def create_file_save_dialog( - parent: typing.Optional[QtCore.QObject] = None, + parent: typing.Optional[QtWidgets.QWidget] = None, default_directory: typing.Optional[trio.Path] = None, default_file: typing.Optional[trio.Path] = None, options: QtWidgets.QFileDialog.Option = QtWidgets.QFileDialog.Option(), ) -> FileDialog: - """ + """Create an open or save dialog. + Arguments: - parent: - default_directory: - default_file: - options: + parent: See :attr:`qtrio.dialogs.FileDialog.parent`. + default_directory: See :attr:`qtrio.dialogs.FileDialog.default_directory`. + default_file: See :attr:`qtrio.dialogs.FileDialog.default_file`. + options: See :attr:`qtrio.dialogs.FileDialog.options`. """ return FileDialog( parent=parent, @@ -414,19 +397,30 @@ def create_file_save_dialog( @check_dialog_protocol @attr.s(auto_attribs=True) class MessageBox: + """Generally instances should be built via :func:`qtrio.dialogs.create_message_box`.""" title: str + """The message box title.""" text: str + """The message text shown inside the dialog.""" icon: QtWidgets.QMessageBox.Icon + """The icon shown inside the dialog.""" buttons: QtWidgets.QMessageBox.StandardButtons - parent: typing.Optional[QtCore.QObject] = None + """The buttons to be shown in the dialog.""" + parent: typing.Optional[QtWidgets.QWidget] = None + """The parent widget for the dialog.""" dialog: typing.Optional[QtWidgets.QMessageBox] = None + """The actual dialog widget instance.""" accept_button: typing.Optional[QtWidgets.QPushButton] = None + """The button to accept the dialog.""" result: typing.Optional[trio.Path] = None + """Not generally relevant for a message box.""" - shown = qtrio._qt.Signal(QtWidgets.QMessageBox) - finished = qtrio._qt.Signal(int) # QtWidgets.QDialog.DialogCode + shown = qtrio.Signal(QtWidgets.QMessageBox) + """See :attr:`qtrio.dialogs.DialogProtocol.shown`.""" + finished = qtrio.Signal(int) # QtWidgets.QDialog.DialogCode + """See :attr:`qtrio.dialogs.DialogProtocol.finished`.""" def setup(self) -> None: """See :meth:`qtrio.dialogs.DialogProtocol.setup`.""" @@ -477,6 +471,15 @@ def create_message_box( text: str, icon: QtWidgets.QMessageBox.Icon = QtWidgets.QMessageBox.Information, buttons: QtWidgets.QMessageBox.StandardButtons = QtWidgets.QMessageBox.Ok, - parent: typing.Optional[QtCore.QObject] = None, + parent: typing.Optional[QtWidgets.QWidget] = None, ) -> MessageBox: + """Create an open or save dialog. + + Arguments: + title: See :attr:`qtrio.dialogs.MessageBox.title`. + text: See :attr:`qtrio.dialogs.MessageBox.text`. + icon: See :attr:`qtrio.dialogs.MessageBox.icon`. + buttons: See :attr:`qtrio.dialogs.MessageBox.buttons`. + parent: See :attr:`qtrio.dialogs.MessageBox.parent`. + """ return MessageBox(icon=icon, title=title, text=text, buttons=buttons, parent=parent) From 1c8580cbbe365ad5c525c466edd89ba01ab288df Mon Sep 17 00:00:00 2001 From: Kyle Altendorf Date: Fri, 21 Aug 2020 16:07:56 -0400 Subject: [PATCH 64/71] typing-extensions for Protocol --- qtrio/dialogs.py | 7 ++++++- setup.py | 1 + 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/qtrio/dialogs.py b/qtrio/dialogs.py index f4724738..093ae8e7 100644 --- a/qtrio/dialogs.py +++ b/qtrio/dialogs.py @@ -3,6 +3,11 @@ import sys import typing +try: + from typing import Protocol +except ImportError: + from typing_extensions import Protocol + import attr from qtpy import QtWidgets import trio @@ -11,7 +16,7 @@ import qtrio._qt -class DialogProtocol(typing.Protocol): +class DialogProtocol(Protocol): """The common interface used for working with QTrio dialogs. To check that a class implements this protocol, decorate it with :func:`qtrio.dialogs.check_dialog_protocol`. diff --git a/setup.py b/setup.py index 581c17a5..907c145a 100644 --- a/setup.py +++ b/setup.py @@ -41,6 +41,7 @@ "pytest", "qtpy", "trio>=0.16", + "typing-extensions", ], extras_require={ "checks": ["black", "flake8", "mypy", towncrier], From cac95559e0ff652b4505287f7b64dec147942e69 Mon Sep 17 00:00:00 2001 From: Kyle Altendorf Date: Fri, 21 Aug 2020 16:17:00 -0400 Subject: [PATCH 65/71] black --- qtrio/dialogs.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/qtrio/dialogs.py b/qtrio/dialogs.py index 093ae8e7..8c404bc1 100644 --- a/qtrio/dialogs.py +++ b/qtrio/dialogs.py @@ -98,6 +98,7 @@ def _dialog_button_box_buttons_by_role( class IntegerDialog: """Manage a dialog for inputting an integer from the user. Generally instances should be built via :func:`qtrio.dialogs.create_integer_dialog`.""" + parent: QtWidgets.QWidget = None """The parent widget for the dialog.""" @@ -403,6 +404,7 @@ def create_file_save_dialog( @attr.s(auto_attribs=True) class MessageBox: """Generally instances should be built via :func:`qtrio.dialogs.create_message_box`.""" + title: str """The message box title.""" text: str From a382a526352129628856befffd9c9b8c3369eb11 Mon Sep 17 00:00:00 2001 From: Kyle Altendorf Date: Fri, 21 Aug 2020 16:44:45 -0400 Subject: [PATCH 66/71] some hints --- qtrio/dialogs.py | 13 +++++++------ setup.py | 3 ++- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/qtrio/dialogs.py b/qtrio/dialogs.py index 8c404bc1..0a512a1b 100644 --- a/qtrio/dialogs.py +++ b/qtrio/dialogs.py @@ -3,11 +3,6 @@ import sys import typing -try: - from typing import Protocol -except ImportError: - from typing_extensions import Protocol - import attr from qtpy import QtWidgets import trio @@ -16,6 +11,12 @@ import qtrio._qt +if sys.version_info >= (3, 8): + from typing import Protocol +else: + from typing_extensions import Protocol + + class DialogProtocol(Protocol): """The common interface used for working with QTrio dialogs. To check that a class implements this protocol, decorate it with @@ -58,7 +59,7 @@ def check_dialog_protocol( @contextlib.contextmanager -def _manage(dialog: DialogProtocol) -> typing.ContextManager[trio.Event]: +def _manage(dialog: DialogProtocol) -> typing.Generator[trio.Event, None, None]: """Manage the setup and teardown of a dialog including yielding a :class:`trio.Event` that is set when the dialog is finished. diff --git a/setup.py b/setup.py index 907c145a..edc2a5aa 100644 --- a/setup.py +++ b/setup.py @@ -41,7 +41,8 @@ "pytest", "qtpy", "trio>=0.16", - "typing-extensions", + # python_version < '3.8' for `Protocol` + "typing-extensions; python_version < '3.8'", ], extras_require={ "checks": ["black", "flake8", "mypy", towncrier], From 89eb5d2d6b48bcbeb79a5f2e37dbde0ab80625a5 Mon Sep 17 00:00:00 2001 From: Kyle Altendorf Date: Fri, 21 Aug 2020 18:40:43 -0400 Subject: [PATCH 67/71] some more # pragma: no cover --- qtrio/dialogs.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/qtrio/dialogs.py b/qtrio/dialogs.py index 0a512a1b..02dc02b6 100644 --- a/qtrio/dialogs.py +++ b/qtrio/dialogs.py @@ -158,7 +158,7 @@ async def wait(self) -> int: integer. """ with _manage(dialog=self) as finished_event: - if self.dialog is None: + if self.dialog is None: # pragma: no cover raise qtrio.InternalError( "Dialog not assigned while it is being managed." ) @@ -247,7 +247,7 @@ async def wait(self) -> str: """See :meth:`qtrio.dialogs.DialogProtocol.teardown`.""" with _manage(dialog=self) as finished_event: - if self.dialog is None: + if self.dialog is None: # pragma: no cover raise qtrio.InternalError( "Dialog not assigned while it is being managed." ) @@ -362,7 +362,7 @@ async def wait(self) -> trio.Path: """See :meth:`qtrio.dialogs.DialogProtocol.teardown`.""" with _manage(dialog=self) as finished_event: - if self.dialog is None: + if self.dialog is None: # pragma: no cover raise qtrio.InternalError( "Dialog not assigned while it is being managed." ) @@ -461,7 +461,7 @@ async def wait(self) -> None: """See :meth:`qtrio.dialogs.DialogProtocol.teardown`.""" with _manage(dialog=self) as finished_event: - if self.dialog is None: + if self.dialog is None: # pragma: no cover raise qtrio.InternalError( "Dialog not assigned while it is being managed." ) From d71d781d4fa08e0758359859aab77f48a75c69cc Mon Sep 17 00:00:00 2001 From: Kyle Altendorf Date: Fri, 21 Aug 2020 19:40:38 -0400 Subject: [PATCH 68/71] more time for macos --- qtrio/_tests/test_dialogs.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/qtrio/_tests/test_dialogs.py b/qtrio/_tests/test_dialogs.py index 8216bafa..7c646163 100644 --- a/qtrio/_tests/test_dialogs.py +++ b/qtrio/_tests/test_dialogs.py @@ -92,7 +92,7 @@ def test_unused_dialog_teardown_ok(builder): dialog.teardown() -@qtrio.host(timeout=10) +@qtrio.host(timeout=30) async def test_file_save(request, qtbot, tmp_path): path_to_select = trio.Path(tmp_path) / "something.new" @@ -149,7 +149,7 @@ async def user(task_status): assert selected_path == path_to_select -@qtrio.host(timeout=10) +@qtrio.host(timeout=30) async def test_file_save_cancelled(request, qtbot, tmp_path): dialog = qtrio.dialogs.create_file_save_dialog() From 39199891121dada4b02e43facdcb3f93157d4210 Mon Sep 17 00:00:00 2001 From: Kyle Altendorf Date: Fri, 21 Aug 2020 20:00:51 -0400 Subject: [PATCH 69/71] always more. more. more! --- .readthedocs.yml | 2 +- qtrio/dialogs.py | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/.readthedocs.yml b/.readthedocs.yml index 594e3065..8f10f374 100644 --- a/.readthedocs.yml +++ b/.readthedocs.yml @@ -9,7 +9,7 @@ build: image: latest python: - version: 3 + version: 3.8 install: - method: pip path: . diff --git a/qtrio/dialogs.py b/qtrio/dialogs.py index 02dc02b6..7850abce 100644 --- a/qtrio/dialogs.py +++ b/qtrio/dialogs.py @@ -19,8 +19,7 @@ class DialogProtocol(Protocol): """The common interface used for working with QTrio dialogs. To check that a class - implements this protocol, decorate it with - :func:`qtrio.dialogs.check_dialog_protocol`. + implements this protocol see :func:`qtrio.dialogs.check_dialog_protocol`. """ shown: qtrio.Signal From 4ea256fb9ab019c88e594711bef1467ce159b3c8 Mon Sep 17 00:00:00 2001 From: Kyle Altendorf Date: Fri, 21 Aug 2020 22:02:09 -0400 Subject: [PATCH 70/71] getting close --- qtrio/_tests/test_dialogs.py | 6 +-- qtrio/dialogs.py | 71 +++++++++++++++++++++++------------- 2 files changed, 49 insertions(+), 28 deletions(-) diff --git a/qtrio/_tests/test_dialogs.py b/qtrio/_tests/test_dialogs.py index 7c646163..b327fea5 100644 --- a/qtrio/_tests/test_dialogs.py +++ b/qtrio/_tests/test_dialogs.py @@ -39,7 +39,7 @@ async def user(task_status): task_status.started() qtbot.keyClicks(dialog.edit_widget, str(test_value)) - qtbot.mouseClick(dialog.ok_button, QtCore.Qt.LeftButton) + qtbot.mouseClick(dialog.accept_button, QtCore.Qt.LeftButton) test_value = 928 @@ -60,7 +60,7 @@ async def user(task_status): task_status.started() qtbot.keyClicks(dialog.edit_widget, "abc") - qtbot.mouseClick(dialog.cancel_button, QtCore.Qt.LeftButton) + qtbot.mouseClick(dialog.reject_button, QtCore.Qt.LeftButton) async with trio.open_nursery() as nursery: await nursery.start(user) @@ -78,7 +78,7 @@ async def user(task_status): task_status.started() qtbot.keyClicks(dialog.edit_widget, "abc") - qtbot.mouseClick(dialog.ok_button, QtCore.Qt.LeftButton) + qtbot.mouseClick(dialog.accept_button, QtCore.Qt.LeftButton) async with trio.open_nursery() as nursery: await nursery.start(user) diff --git a/qtrio/dialogs.py b/qtrio/dialogs.py index 7850abce..047239f0 100644 --- a/qtrio/dialogs.py +++ b/qtrio/dialogs.py @@ -37,7 +37,10 @@ def teardown(self) -> None: async def wait(self) -> object: """Show the dialog, wait for the user interaction, and return the result. - Raises :class:`qtrio.UserCancelledError` if the user cancels the dialog. + + Raises: + qtrio.InvalidInputError: If the input can't be parsed as an integer. + qtrio.UserCancelledError: If the user cancels the dialog. """ @@ -47,12 +50,12 @@ async def wait(self) -> object: def check_dialog_protocol( cls: typing.Type[DialogProtocolT], ) -> typing.Type[DialogProtocolT]: - """Decorate a class with this to verify it implements the :class:`DialogProtocol` - when a type hint checker such as mypy is run against the code. At runtime the - passed class is cleanly returned. + """Decorate a class with this to verify it implements the + :class:`qtrio.dialogs.DialogProtocol` when a type hint checker such as mypy is run + against the code. At runtime the passed class is cleanly returned. Arguments: - cls: + cls: The class to verify. """ return cls @@ -82,7 +85,7 @@ def slot(*args: object, **kwargs: object) -> None: def _dialog_button_box_buttons_by_role( dialog: QtWidgets.QDialog, ) -> typing.Mapping[QtWidgets.QDialogButtonBox.ButtonRole, QtWidgets.QAbstractButton]: - """Create mapping from button roles to their corresponding buttons.""" + """Create a mapping from button roles to their corresponding buttons.""" hits = dialog.findChildren(QtWidgets.QDialogButtonBox) @@ -99,16 +102,16 @@ class IntegerDialog: """Manage a dialog for inputting an integer from the user. Generally instances should be built via :func:`qtrio.dialogs.create_integer_dialog`.""" - parent: QtWidgets.QWidget = None + parent: typing.Optional[QtWidgets.QWidget] = None """The parent widget for the dialog.""" dialog: typing.Optional[QtWidgets.QInputDialog] = None """The actual dialog widget instance.""" edit_widget: typing.Optional[QtWidgets.QLineEdit] = None """The line edit that the user will enter the input into.""" - ok_button: typing.Optional[QtWidgets.QPushButton] = None + accept_button: typing.Optional[QtWidgets.QPushButton] = None """The entry confirmation button.""" - cancel_button: typing.Optional[QtWidgets.QPushButton] = None + reject_button: typing.Optional[QtWidgets.QPushButton] = None """The input cancellation button.""" result: typing.Optional[int] = None @@ -132,8 +135,8 @@ def setup(self) -> None: self.dialog.show() buttons = _dialog_button_box_buttons_by_role(dialog=self.dialog) - self.ok_button = buttons.get(QtWidgets.QDialogButtonBox.AcceptRole) - self.cancel_button = buttons.get(QtWidgets.QDialogButtonBox.RejectRole) + self.accept_button = buttons.get(QtWidgets.QDialogButtonBox.AcceptRole) + self.reject_button = buttons.get(QtWidgets.QDialogButtonBox.RejectRole) [self.edit_widget] = self.dialog.findChildren(QtWidgets.QLineEdit) @@ -146,16 +149,12 @@ def teardown(self) -> None: self.dialog.close() self.dialog.finished.disconnect(self.finished) self.dialog = None - self.ok_button = None - self.cancel_button = None + self.accept_button = None + self.reject_button = None self.edit_widget = None async def wait(self) -> int: - """Setup the dialog, wait for the user input, teardown, and return the user - input. Raises :exc:`qtrio.UserCancelledError` if the user cancels the dialog. - Raises :exc:`qtrio.InvalidInputError` if the input can't be parsed as an - integer. - """ + """See :meth:`qtrio.dialogs.DialogProtocol.wait`.""" with _manage(dialog=self) as finished_event: if self.dialog is None: # pragma: no cover raise qtrio.InternalError( @@ -175,7 +174,17 @@ async def wait(self) -> int: return self.result -def create_integer_dialog(parent: QtWidgets.QWidget = None,) -> IntegerDialog: +def create_integer_dialog( + parent: typing.Optional[QtWidgets.QWidget] = None, +) -> IntegerDialog: + """Create an integer input dialog. + + Arguments: + parent: See :attr:`qtrio.dialogs.IntegerDialog.parent`. + + Returns: + The dialog manager. + """ return IntegerDialog(parent=parent) @@ -243,7 +252,7 @@ def teardown(self) -> None: self.reject_button = None async def wait(self) -> str: - """See :meth:`qtrio.dialogs.DialogProtocol.teardown`.""" + """See :meth:`qtrio.dialogs.DialogProtocol.wait`.""" with _manage(dialog=self) as finished_event: if self.dialog is None: # pragma: no cover @@ -272,6 +281,16 @@ def create_text_input_dialog( label: typing.Optional[str] = None, parent: typing.Optional[QtWidgets.QWidget] = None, ) -> TextInputDialog: + """Create a text input dialog. + + Arguments: + title: The text to use for the input dialog title bar. + label: The text to use for the input text box label. + parent: See :attr:`qtrio.dialogs.IntegerDialog.parent`. + + Returns: + The dialog manager. + """ return TextInputDialog(title=title, label=label, parent=parent) @@ -292,7 +311,7 @@ class FileDialog: default_file: typing.Optional[trio.Path] = None """The file to be initially selected in the dialog.""" options: QtWidgets.QFileDialog.Option = QtWidgets.QFileDialog.Option() - """Miscellanious options. See the Qt documentation.""" + """Miscellaneous options. See the Qt documentation.""" parent: typing.Optional[QtWidgets.QWidget] = None """The parent widget for the dialog.""" @@ -358,7 +377,7 @@ def teardown(self) -> None: self.reject_button = None async def wait(self) -> trio.Path: - """See :meth:`qtrio.dialogs.DialogProtocol.teardown`.""" + """See :meth:`qtrio.dialogs.DialogProtocol.wait`.""" with _manage(dialog=self) as finished_event: if self.dialog is None: # pragma: no cover @@ -403,7 +422,9 @@ def create_file_save_dialog( @check_dialog_protocol @attr.s(auto_attribs=True) class MessageBox: - """Generally instances should be built via :func:`qtrio.dialogs.create_message_box`.""" + """Manage a message box for notifying the user. Generally instances should be built + via :func:`qtrio.dialogs.create_message_box`. + """ title: str """The message box title.""" @@ -457,7 +478,7 @@ def teardown(self) -> None: self.accept_button = None async def wait(self) -> None: - """See :meth:`qtrio.dialogs.DialogProtocol.teardown`.""" + """See :meth:`qtrio.dialogs.DialogProtocol.wait`.""" with _manage(dialog=self) as finished_event: if self.dialog is None: # pragma: no cover @@ -480,7 +501,7 @@ def create_message_box( buttons: QtWidgets.QMessageBox.StandardButtons = QtWidgets.QMessageBox.Ok, parent: typing.Optional[QtWidgets.QWidget] = None, ) -> MessageBox: - """Create an open or save dialog. + """Create a message box. Arguments: title: See :attr:`qtrio.dialogs.MessageBox.title`. From da40b9714e8f57292de2ccfe2d0d46d6f5c03e68 Mon Sep 17 00:00:00 2001 From: Kyle Altendorf Date: Fri, 21 Aug 2020 22:06:07 -0400 Subject: [PATCH 71/71] trickle --- qtrio/_qt.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/qtrio/_qt.py b/qtrio/_qt.py index ad06eb87..b3a1764b 100644 --- a/qtrio/_qt.py +++ b/qtrio/_qt.py @@ -10,10 +10,10 @@ class Signal: - """This is a (nearly) drop-in replacement for QtCore.Signal. The useful difference - is that it does not require inheriting from :class:`QtCore.QObject`. The not-quite - part is that it will be a bit more complicated to change thread affinity of the - relevant :class:`QtCore.QObject`. If you need this, maybe just inherit. + """This is a (nearly) drop-in replacement for :class:`QtCore.Signal`. The useful + difference is that it does not require inheriting from :class:`QtCore.QObject`. The + not-quite part is that it will be a bit more complicated to change thread affinity + of the relevant :class:`QtCore.QObject`. If you need this, maybe just inherit. This signal gets around the normally required inheritance by creating :class:`QtCore.QObject` instances behind the scenes to host the real signals. Just