diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cab95194..43d57db6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -107,7 +107,12 @@ jobs: matrix: python: ['3.6', '3.7', '3.8', '3.9'] arch: ['x86', 'x64'] - qt_library: ['PyQt5', 'PySide2'] + qt_library: ['PySide2', 'PyQt5', 'PySide6', 'PyQt6'] + exclude: + - arch: x86 + qt_library: PySide6 + - arch: x86 + qt_library: PyQt6 steps: - name: Checkout uses: actions/checkout@v2 @@ -135,7 +140,7 @@ jobs: fail-fast: false matrix: python: ['3.6', '3.7', '3.8', '3.9'] - qt_library: ['PyQt5', 'PySide2'] + qt_library: ['PySide2', 'PyQt5', 'PySide6', 'PyQt6'] steps: - name: Checkout uses: actions/checkout@v2 @@ -163,7 +168,7 @@ jobs: fail-fast: false matrix: python: ['3.6', '3.7', '3.8', '3.9'] - qt_library: ['PyQt5', 'PySide2'] + qt_library: ['PySide2', 'PyQt5', 'PySide6', 'PyQt6'] steps: - name: Checkout uses: actions/checkout@v2 diff --git a/docs/source/conf.py b/docs/source/conf.py index fbd700e4..c8e8bfac 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -45,6 +45,7 @@ "py:class", "Union[, PySide2.QtWidgets.QMessageBox.StandardButtons]", ), + ("py:class", ""), ] # -- General configuration ------------------------------------------------ diff --git a/qtrio/_core.py b/qtrio/_core.py index 3ec962ae..893bb489 100644 --- a/qtrio/_core.py +++ b/qtrio/_core.py @@ -13,6 +13,7 @@ import attr import outcome import qts +import qts.util import trio import trio.abc @@ -62,16 +63,12 @@ def register_event_type() -> None: # assign to the global # TODO: https://bugreports.qt.io/browse/PYSIDE-1347 - if qts.is_pyqt_5_wrapper: - _reenter_event_type = QtCore.QEvent.Type(event_hint) - elif qts.is_pyside_5_wrapper: + if qts.is_pyside_5_wrapper: _reenter_event_type = typing.cast( typing.Callable[[int], QtCore.QEvent.Type], QtCore.QEvent.Type )(event_hint) - else: # pragma: no cover - raise qtrio.InternalError( - "You should not be here but you are running neither PyQt5 nor PySide2.", - ) + else: + _reenter_event_type = QtCore.QEvent.Type(event_hint) def register_requested_event_type( @@ -98,17 +95,13 @@ def register_requested_event_type( raise qtrio.EventTypeAlreadyRegisteredError() # TODO: https://bugreports.qt.io/browse/PYSIDE-1468 - if qts.is_pyqt_5_wrapper: - event_hint = QtCore.QEvent.registerEventType(requested_value) - elif qts.is_pyside_5_wrapper: + if qts.is_pyside_5_wrapper: event_hint = typing.cast( typing.Callable[[typing.Union[int, QtCore.QEvent.Type]], int], QtCore.QEvent.registerEventType, )(requested_value) - else: # pragma: no cover - raise qtrio.InternalError( - "You should not be here but you are running neither PyQt5 nor PySide2.", - ) + else: + event_hint = QtCore.QEvent.registerEventType(requested_value) if event_hint == -1: raise qtrio.EventTypeRegistrationFailedError() @@ -119,16 +112,12 @@ def register_requested_event_type( # assign to the global # TODO: https://bugreports.qt.io/browse/PYSIDE-1347 - if qts.is_pyqt_5_wrapper: - _reenter_event_type = QtCore.QEvent.Type(event_hint) - elif qts.is_pyside_5_wrapper: + if qts.is_pyside_5_wrapper: _reenter_event_type = typing.cast( typing.Callable[[int], QtCore.QEvent.Type], QtCore.QEvent.Type )(event_hint) - else: # pragma: no cover - raise qtrio.InternalError( - "You should not be here but you are running neither PyQt5 nor PySide2.", - ) + else: + _reenter_event_type = QtCore.QEvent.Type(event_hint) async def wait_signal(signal: "QtCore.SignalInstance") -> typing.Tuple[object, ...]: @@ -540,17 +529,13 @@ def maybe_build_application() -> "QtGui.QGuiApplication": application: QtCore.QCoreApplication # TODO: https://bugreports.qt.io/browse/PYSIDE-1467 - if qts.is_pyqt_5_wrapper: - maybe_application = QtWidgets.QApplication.instance() - elif qts.is_pyside_5_wrapper: + if qts.is_pyside_5_wrapper: maybe_application = typing.cast( typing.Optional["QtCore.QCoreApplication"], QtWidgets.QApplication.instance(), ) - else: # pragma: no cover - raise qtrio.InternalError( - "You should not be here but you are running neither PyQt5 nor PySide2.", - ) + else: + maybe_application = QtWidgets.QApplication.instance() if maybe_application is None: application = QtWidgets.QApplication(sys.argv[1:]) @@ -649,7 +634,7 @@ def run( ) if execute_application: - return_code = self.application.exec_() + return_code = qts.util.exec(self.application) self.outcomes = attr.evolve( self.outcomes, diff --git a/qtrio/_qt.py b/qtrio/_qt.py index c44999dd..4c571096 100644 --- a/qtrio/_qt.py +++ b/qtrio/_qt.py @@ -113,7 +113,11 @@ def connection( # if you get segfault or sigsegv here, especially from pyside2<5.15.2, make # sure the slot isn't on a non-hashable (frozen will make it hashable) attrs # class. https://bugreports.qt.io/browse/PYSIDE-1422 - this_connection = signal.connect(slot) + try: + this_connection = signal.connect(slot) + except TypeError as e: + print(e) + raise import qts diff --git a/qtrio/_tests/examples/readme/test_qt.py b/qtrio/_tests/examples/readme/test_qt.py index 5d61626b..f781fd10 100644 --- a/qtrio/_tests/examples/readme/test_qt.py +++ b/qtrio/_tests/examples/readme/test_qt.py @@ -19,15 +19,14 @@ def test_main(qtbot: pytestqt.qtbot.QtBot, qapp: QtWidgets.QApplication) -> None output_dialog=output_dialog, ) - main_object.setup() - - qtbot.wait_for_window_shown(input_dialog) + with qtbot.wait_exposed(widget=input_dialog): + main_object.setup() [line_edit] = input_dialog.findChildren(QtWidgets.QLineEdit) line_edit.setText(text_to_enter) - input_dialog.accept() - qtbot.wait_for_window_shown(output_dialog) + with qtbot.wait_exposed(widget=output_dialog): + input_dialog.accept() output_text = output_dialog.text() @@ -53,9 +52,8 @@ def test_main_cancelled( output_dialog=output_dialog, ) - main_object.setup() - - qtbot.wait_for_window_shown(input_dialog) + with qtbot.wait_exposed(widget=input_dialog): + main_object.setup() [line_edit] = input_dialog.findChildren(QtWidgets.QLineEdit) line_edit.setText(text_to_enter) diff --git a/qtrio/_tests/examples/test_crossingpaths.py b/qtrio/_tests/examples/test_crossingpaths.py index 0700967a..38beab49 100644 --- a/qtrio/_tests/examples/test_crossingpaths.py +++ b/qtrio/_tests/examples/test_crossingpaths.py @@ -22,7 +22,7 @@ async def test_main( hold_event=optional_hold_event, ) widget: qtrio.examples.crossingpaths.Widget = await nursery.start(start) - qtbot.addWidget(widget) + qtbot.addWidget(widget.label) async with qtrio.enter_emissions_channel( signals=[widget.text_changed], diff --git a/qtrio/_tests/helpers.py b/qtrio/_tests/helpers.py index 71099d99..6ba6402e 100644 --- a/qtrio/_tests/helpers.py +++ b/qtrio/_tests/helpers.py @@ -6,10 +6,10 @@ @pytest.fixture(name="qtrio_preshow_workaround", scope="session", autouse=True) def qtrio_preshow_workaround_fixture(qapp): dialog = QtWidgets.QMessageBox( - QtWidgets.QMessageBox.Information, + QtWidgets.QMessageBox.Icon.Information, "", "", - QtWidgets.QMessageBox.Ok, + QtWidgets.QMessageBox.StandardButton.Ok, ) dialog.show() diff --git a/qtrio/_tests/test_core.py b/qtrio/_tests/test_core.py index 142277bf..8e1b8279 100644 --- a/qtrio/_tests/test_core.py +++ b/qtrio/_tests/test_core.py @@ -5,6 +5,7 @@ import outcome import pytest from qts import QtCore +from qts import QtWidgets import qtrio import qtrio._core import trio @@ -393,7 +394,7 @@ def test(): pass with pytest.raises(qtrio.EventTypeRegistrationFailedError): - qtrio.register_requested_event_type(QtCore.QEvent.User) + qtrio.register_requested_event_type(QtCore.QEvent.Type.User) """ testdir.makepyfile(test_file) @@ -431,9 +432,9 @@ def test_requesting_available_event_type_succeeds(testdir): def test(): - qtrio.register_requested_event_type(QtCore.QEvent.User) + qtrio.register_requested_event_type(QtCore.QEvent.Type.User) - assert qtrio.registered_event_type() == QtCore.QEvent.User + assert qtrio.registered_event_type() == QtCore.QEvent.Type.User """ testdir.makepyfile(test_file) @@ -671,6 +672,27 @@ async def test(): ) +def test_qobject_destroyed_signal_equality(qapp): + """Verify that the underlying signal objects can be compared by equality. + + https://bugreports.qt.io/browse/PYSIDE-1431 + """ + q_object = QtCore.QObject() + + assert q_object.destroyed == q_object.destroyed + + +def test_qpushbutton_clicked_signal_equality(qapp): + """Verify that the underlying signal objects can be compared by equality even when + the signal is inherited. + + https://bugreports.qt.io/browse/PYSIDE-1431 + """ + button = QtWidgets.QPushButton() + + assert button.clicked == button.clicked + + def test_emissions_equal(): """:class:`Emission` objects created from the same :class:`QtCore.Signal` instance and args are equal even if the attributes are different instances. @@ -686,6 +708,18 @@ class C(QtCore.QObject): ) == qtrio._core.Emission(signal=instance.signal, args=(13,)) +def test_emissions_for_buttons(): + """:class:`Emission` objects created from the same :class:`QtWidgets.QPushButton` + instance and args are equal even if the attributes are different instances. + """ + + instance = QtWidgets.QPushButton() + + assert qtrio._core.Emission( + signal=instance.clicked, args=(13,) + ) == qtrio._core.Emission(signal=instance.clicked, args=(13,)) + + def test_emissions_unequal_by_signal(): """:class:`Emission` objects with the same arguments but different signals are unequal. @@ -1154,6 +1188,8 @@ def test_execute_manually(testdir): """Executing manually works.""" test_file = r""" + import qts.util + import qtrio @@ -1169,7 +1205,7 @@ async def async_fn(): assert not ran - runner.application.exec_() + qts.util.exec(runner.application) assert ran """ diff --git a/qtrio/_tests/test_dialogs.py b/qtrio/_tests/test_dialogs.py index 3c79e5c6..23d8a435 100644 --- a/qtrio/_tests/test_dialogs.py +++ b/qtrio/_tests/test_dialogs.py @@ -51,7 +51,7 @@ async def user(task_status): task_status.started() qtbot.keyClicks(dialog.edit_widget, str(test_value)) - qtbot.mouseClick(dialog.accept_button, QtCore.Qt.LeftButton) + qtbot.mouseClick(dialog.accept_button, QtCore.Qt.MouseButton.LeftButton) test_value = 928 @@ -73,7 +73,7 @@ async def user(task_status): task_status.started() qtbot.keyClicks(dialog.edit_widget, "abc") - qtbot.mouseClick(dialog.reject_button, QtCore.Qt.LeftButton) + qtbot.mouseClick(dialog.reject_button, QtCore.Qt.MouseButton.LeftButton) async with trio.open_nursery() as nursery: await nursery.start(user) @@ -92,7 +92,7 @@ async def user(task_status): task_status.started() qtbot.keyClicks(dialog.edit_widget, "abc") - qtbot.mouseClick(dialog.accept_button, QtCore.Qt.LeftButton) + qtbot.mouseClick(dialog.accept_button, QtCore.Qt.MouseButton.LeftButton) async with trio.open_nursery() as nursery: await nursery.start(user) @@ -229,7 +229,7 @@ async def test_information_message_box(qtbot: pytestqt.qtbot.QtBot) -> None: dialog = qtrio.dialogs.create_message_box( title="Information", text=text, - icon=QtWidgets.QMessageBox.Information, + icon=QtWidgets.QMessageBox.Icon.Information, ) async def user(task_status): @@ -255,8 +255,11 @@ async def test_information_message_box_cancel(qtbot: pytestqt.qtbot.QtBot) -> No dialog = qtrio.dialogs.create_message_box( title="", text="", - icon=QtWidgets.QMessageBox.Information, - buttons=QtWidgets.QMessageBox.Ok | QtWidgets.QMessageBox.Cancel, + icon=QtWidgets.QMessageBox.Icon.Information, + buttons=( + QtWidgets.QMessageBox.StandardButton.Ok + | QtWidgets.QMessageBox.StandardButton.Cancel + ), ) async def user(task_status): diff --git a/qtrio/_tests/test_qt.py b/qtrio/_tests/test_qt.py index 817f245f..12e32c37 100644 --- a/qtrio/_tests/test_qt.py +++ b/qtrio/_tests/test_qt.py @@ -30,7 +30,7 @@ class NotQObject: instance = NotQObject() - with qtbot.wait_signal(instance.signal, 100): + with qtbot.wait_signal(signal=instance.signal, timeout=100): instance.signal.emit() @@ -52,7 +52,7 @@ def collect_result(value): instance = NotQObject() instance.signal.connect(collect_result) - with qtbot.wait_signal(instance.signal, 100): + with qtbot.wait_signal(signal=instance.signal, timeout=100): instance.signal.emit(13) assert result == 13 diff --git a/qtrio/dialogs.py b/qtrio/dialogs.py index c9e2861f..1f091be4 100644 --- a/qtrio/dialogs.py +++ b/qtrio/dialogs.py @@ -154,8 +154,12 @@ def setup(self) -> None: self.dialog.show() 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.accept_button = buttons.get( + QtWidgets.QDialogButtonBox.ButtonRole.AcceptRole, + ) + self.reject_button = buttons.get( + QtWidgets.QDialogButtonBox.ButtonRole.RejectRole, + ) [self.edit_widget] = self.dialog.findChildren(QtWidgets.QLineEdit) @@ -182,7 +186,7 @@ async def wait(self) -> int: await finished_event.wait() - if self.dialog.result() != QtWidgets.QDialog.Accepted: + if self.dialog.result() != QtWidgets.QDialog.DialogCode.Accepted: raise qtrio.UserCancelledError() try: @@ -255,8 +259,8 @@ def setup(self) -> None: 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[QtWidgets.QDialogButtonBox.ButtonRole.AcceptRole] + self.reject_button = buttons[QtWidgets.QDialogButtonBox.ButtonRole.RejectRole] [self.line_edit] = self.dialog.findChildren(QtWidgets.QLineEdit) @@ -287,7 +291,7 @@ async def wait(self, shown_event: trio.Event = trio.Event()) -> str: dialog_result = self.dialog.result() - if dialog_result == QtWidgets.QDialog.Rejected: + if dialog_result == QtWidgets.QDialog.DialogCode.Rejected: raise qtrio.UserCancelledError() # TODO: `: str` is a workaround for @@ -333,7 +337,7 @@ class FileDialog: """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() + options: QtWidgets.QFileDialog.Option = QtWidgets.QFileDialog.Option(0) """Miscellaneous options. See the Qt documentation.""" parent: typing.Optional[QtWidgets.QWidget] = None """The parent widget for the dialog.""" @@ -408,8 +412,12 @@ def setup(self) -> None: self.dialog.show() 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.accept_button = buttons.get( + QtWidgets.QDialogButtonBox.ButtonRole.AcceptRole, + ) + self.reject_button = buttons.get( + QtWidgets.QDialogButtonBox.ButtonRole.RejectRole, + ) [self.file_name_line_edit] = self.dialog.findChildren(QtWidgets.QLineEdit) self.shown.emit(self.dialog) @@ -437,7 +445,7 @@ async def wait(self, shown_event: trio.Event = trio.Event()) -> trio.Path: shown_event.set() await finished_event.wait() - if self.dialog.result() != QtWidgets.QDialog.Accepted: + if self.dialog.result() != QtWidgets.QDialog.DialogCode.Accepted: raise qtrio.UserCancelledError() [path_string] = self.dialog.selectedFiles() @@ -450,7 +458,7 @@ def create_file_save_dialog( 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(), + options: QtWidgets.QFileDialog.Option = QtWidgets.QFileDialog.Option(0), ) -> FileDialog: """Create a file save dialog. @@ -465,8 +473,8 @@ def create_file_save_dialog( default_directory=default_directory, default_file=default_file, options=options, - file_mode=QtWidgets.QFileDialog.AnyFile, - accept_mode=QtWidgets.QFileDialog.AcceptSave, + file_mode=QtWidgets.QFileDialog.FileMode.AnyFile, + accept_mode=QtWidgets.QFileDialog.AcceptMode.AcceptSave, ) @@ -474,7 +482,7 @@ def create_file_open_dialog( 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(), + options: QtWidgets.QFileDialog.Option = QtWidgets.QFileDialog.Option(0), ) -> FileDialog: """Create a file open dialog. @@ -489,14 +497,13 @@ def create_file_open_dialog( default_directory=default_directory, default_file=default_file, options=options, - file_mode=QtWidgets.QFileDialog.AnyFile, - accept_mode=QtWidgets.QFileDialog.AcceptOpen, + file_mode=QtWidgets.QFileDialog.FileMode.AnyFile, + accept_mode=QtWidgets.QFileDialog.AcceptMode.AcceptOpen, ) message_box_standard_button_union = typing.Union[ QtWidgets.QMessageBox.StandardButton, - QtWidgets.QMessageBox.StandardButtons, ] @@ -550,7 +557,7 @@ def setup(self) -> None: self.dialog.show() buttons = _dialog_button_box_buttons_by_role(dialog=self.dialog) - self.accept_button = buttons[QtWidgets.QDialogButtonBox.AcceptRole] + self.accept_button = buttons[QtWidgets.QDialogButtonBox.ButtonRole.AcceptRole] self.shown.emit(self.dialog) @@ -577,15 +584,15 @@ async def wait(self, shown_event: trio.Event = trio.Event()) -> None: result = self.dialog.result() - if result == QtWidgets.QDialog.Rejected: + if result == QtWidgets.QDialog.DialogCode.Rejected: raise qtrio.UserCancelledError() def create_message_box( title: str = "", text: str = "", - icon: QtWidgets.QMessageBox.Icon = QtWidgets.QMessageBox.Information, - buttons: message_box_standard_button_union = QtWidgets.QMessageBox.Ok, + icon: QtWidgets.QMessageBox.Icon = QtWidgets.QMessageBox.Icon.Information, + buttons: message_box_standard_button_union = QtWidgets.QMessageBox.StandardButton.Ok, parent: typing.Optional[QtWidgets.QWidget] = None, ) -> MessageBox: """Create a message box. diff --git a/qtrio/examples/emissions.py b/qtrio/examples/emissions.py index 4b477f12..ed833d9b 100644 --- a/qtrio/examples/emissions.py +++ b/qtrio/examples/emissions.py @@ -26,15 +26,11 @@ def closeEvent(self, event: QtGui.QCloseEvent) -> None: super().closeEvent(event) if event.isAccepted(): # TODO: https://bugreports.qt.io/browse/PYSIDE-1318 - if qts.is_pyqt_5_wrapper: - self.closed.emit() - elif qts.is_pyside_5_wrapper: + if qts.is_pyside_5_wrapper: signal = typing.cast(QtCore.SignalInstance, self.closed) signal.emit() - else: # pragma: no cover - raise qtrio.InternalError( - "You should not be here but you are running neither PyQt5 nor PySide2.", - ) + else: + self.closed.emit() else: # pragma: no cover pass @@ -44,15 +40,11 @@ def showEvent(self, event: QtGui.QShowEvent) -> None: super().showEvent(event) if event.isAccepted(): # TODO: https://bugreports.qt.io/browse/PYSIDE-1318 - if qts.is_pyqt_5_wrapper: - self.shown.emit() - elif qts.is_pyside_5_wrapper: + if qts.is_pyside_5_wrapper: signal = typing.cast(QtCore.SignalInstance, self.shown) signal.emit() - else: # pragma: no cover - raise qtrio.InternalError( - "You should not be here but you are running neither PyQt5 nor PySide2.", - ) + else: + self.shown.emit() else: # pragma: no cover pass diff --git a/qtrio/examples/readme/qt.py b/qtrio/examples/readme/qt.py index a24e23b4..db20ddc7 100644 --- a/qtrio/examples/readme/qt.py +++ b/qtrio/examples/readme/qt.py @@ -16,7 +16,7 @@ def create_output() -> QtWidgets.QMessageBox: QtWidgets.QMessageBox.Icon.Question, "Hello", "", - QtWidgets.QMessageBox.Ok, + QtWidgets.QMessageBox.StandardButton.Ok, ) diff --git a/qtrio/examples/readme/qtrio_example.py b/qtrio/examples/readme/qtrio_example.py index 16235168..5efbde77 100644 --- a/qtrio/examples/readme/qtrio_example.py +++ b/qtrio/examples/readme/qtrio_example.py @@ -21,7 +21,7 @@ def create_output() -> qtrio.dialogs.MessageBox: title="Hello", text="", icon=QtWidgets.QMessageBox.Icon.Question, - buttons=QtWidgets.QMessageBox.Ok, + buttons=QtWidgets.QMessageBox.StandardButton.Ok, ) diff --git a/setup.cfg b/setup.cfg index 750bffd7..ddaea51b 100644 --- a/setup.cfg +++ b/setup.cfg @@ -55,13 +55,17 @@ console_scripts = qtrio = qtrio._cli:cli [options.extras_require] -pyqt5 = - # >= 5.15.1 for https://www.riverbankcomputing.com/pipermail/pyqt/2020-July/043064.html - pyqt5 ~=5.15.1 - pyqt5-stubs ~=5.15.2 pyside2 = # != 5.15.2 for https://bugreports.qt.io/browse/PYSIDE-1431 pyside2 ~= 5.15, != 5.15.2 +pyside6 = + pyside6 ~= 6.1 +pyqt5 = + # >= 5.15.1 for https://www.riverbankcomputing.com/pipermail/pyqt/2020-July/043064.html + pyqt5 ~= 5.15.1 + pyqt5-stubs ~= 5.15.2 +pyqt6 = + pyqt6 ~= 6.1 cli = click ~= 7.0 examples = @@ -80,7 +84,7 @@ p_checks = flake8 ~= 3.8 mypy == 0.790 pytest ~= 6.2 - quart_trio ~= 0.7.0 + %(s_quart_trio)s %(s_towncrier)s %(examples)s p_docs = @@ -99,7 +103,7 @@ p_tests = %(s_pytest)s pytest-cov ~= 2.10 pytest-faulthandler ~= 2.0 - pytest-qt ~= 3.3 + pytest-qt ~= 4.0 %(s_pytest_trio)s pytest-xdist[psutil] ~= 2.2 pytest-xvfb ~= 2.0; sys_platform == "linux"