Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

migrate to qtpy #478

Open
wants to merge 3 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,5 @@ src/pytest_qt.egg-info

# auto-generated by setuptools_scm
/src/pytestqt/_version.py
# PyCharm
.idea
2 changes: 1 addition & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ repos:
rev: 1.13.0
hooks:
- id: blacken-docs
additional_dependencies: [black==20.8b1]
additional_dependencies: [black]
language_version: python3
- repo: https://github.com/PyCQA/flake8
rev: 6.0.0
Expand Down
2 changes: 1 addition & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
packages=find_packages(where="src"),
package_dir={"": "src"},
entry_points={"pytest11": ["pytest-qt = pytestqt.plugin"]},
install_requires=["pytest>=3.0.0"],
install_requires=["pytest>=3.0.0", "qtpy"],
extras_require={
"doc": ["sphinx", "sphinx_rtd_theme"],
"dev": ["pre-commit", "tox"],
Expand Down
17 changes: 2 additions & 15 deletions src/pytestqt/plugin.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import os
import warnings

import pytest
Expand Down Expand Up @@ -226,18 +227,4 @@ def pytest_configure(config):

if config.getoption("qt_log") and config.getoption("capture") != "no":
config.pluginmanager.register(QtLoggingPlugin(config), "_qt_logging")

qt_api.set_qt_api(config.getini("qt_api"))


def pytest_report_header():
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should definitely stay - it's very useful information for test runs, and it missing also currently breaks a lot of self tests.

from pytestqt.qt_compat import qt_api

v = qt_api.get_versions()
fields = [
f"{v.qt_api} {v.qt_api_version}",
"Qt runtime %s" % v.runtime,
"Qt compiled %s" % v.compiled,
]
version_line = " -- ".join(fields)
return [version_line]
os.environ.setdefault("QT_API", config.getini("qt_api"))
212 changes: 18 additions & 194 deletions src/pytestqt/qt_compat.py
Original file line number Diff line number Diff line change
@@ -1,200 +1,24 @@
"""
Provide a common way to import Qt classes used by pytest-qt in a unique manner,
abstracting API differences between PyQt5/6 and PySide2/6.
import qtpy

.. note:: This module is not part of pytest-qt public API, hence its interface
may change between releases and users should not rely on it.

Based on from https://github.com/epage/PythonUtils.
"""
from qtpy import QtTest, QtCore, QtWidgets, QtQuick, QtQml, QtGui


from collections import namedtuple, OrderedDict
import os
import sys
class qt_api:
QT_API = qtpy.API
QtCore = QtCore
QtTest = QtTest
QtGui = QtGui
QtWidgets = QtWidgets
QtQuick = QtQuick
QtQml = QtQml
pytest_qt_api = qtpy.API
Signal = QtCore.Signal

import pytest
qDebug = QtCore.qDebug
qCritical = QtCore.qCritical
qInfo = QtCore.qInfo
qWarning = QtCore.qWarning
is_pyside = qtpy.API in (qtpy.PYSIDE2, qtpy.PYSIDE6)


VersionTuple = namedtuple("VersionTuple", "qt_api, qt_api_version, runtime, compiled")

QT_APIS = OrderedDict()
QT_APIS["pyside6"] = "PySide6"
QT_APIS["pyside2"] = "PySide2"
QT_APIS["pyqt6"] = "PyQt6"
QT_APIS["pyqt5"] = "PyQt5"


def _import(name):
"""Think call so we can mock it during testing"""
return __import__(name)


def _is_library_loaded(name):
return name in sys.modules


class _QtApi:
"""
Interface to the underlying Qt API currently configured for pytest-qt.

This object lazily loads all class references and other objects when the ``set_qt_api`` method
gets called, providing a uniform way to access the Qt classes.
"""

def __init__(self):
self._import_errors = {}

def _get_qt_api_from_env(self):
api = os.environ.get("PYTEST_QT_API")
supported_apis = QT_APIS.keys()

if api is not None:
api = api.lower()
if api not in supported_apis: # pragma: no cover
msg = f"Invalid value for $PYTEST_QT_API: {api}, expected one of {supported_apis}"
raise pytest.UsageError(msg)
return api

def _get_already_loaded_backend(self):
for api, backend in QT_APIS.items():
if _is_library_loaded(backend):
return api
return None

def _guess_qt_api(self): # pragma: no cover
def _can_import(name):
try:
_import(name)
return True
except ModuleNotFoundError as e:
self._import_errors[name] = str(e)
return False

# Note, not importing only the root namespace because when uninstalling from conda,
# the namespace can still be there.
for api, backend in QT_APIS.items():
if _can_import(f"{backend}.QtCore"):
return api
return None

def set_qt_api(self, api):
self.pytest_qt_api = (
self._get_qt_api_from_env()
or api
or self._get_already_loaded_backend()
or self._guess_qt_api()
)

self.is_pyside = self.pytest_qt_api in ["pyside2", "pyside6"]
self.is_pyqt = self.pytest_qt_api in ["pyqt5", "pyqt6"]

if not self.pytest_qt_api: # pragma: no cover
errors = "\n".join(
f" {module}: {reason}"
for module, reason in sorted(self._import_errors.items())
)
msg = (
"pytest-qt requires either PySide2, PySide6, PyQt5 or PyQt6 installed.\n"
+ errors
)
raise pytest.UsageError(msg)

_root_module = QT_APIS[self.pytest_qt_api]

def _import_module(module_name):
m = __import__(_root_module, globals(), locals(), [module_name], 0)
return getattr(m, module_name)

self.QtCore = QtCore = _import_module("QtCore")
self.QtGui = _import_module("QtGui")
self.QtTest = _import_module("QtTest")
self.QtWidgets = _import_module("QtWidgets")

self._check_qt_api_version()

# qInfo is not exposed in PySide2/6 (#232)
if hasattr(QtCore, "QMessageLogger"):
self.qInfo = lambda msg: QtCore.QMessageLogger().info(msg)
elif hasattr(QtCore, "qInfo"):
self.qInfo = QtCore.qInfo
else:
self.qInfo = None

self.qDebug = QtCore.qDebug
self.qWarning = QtCore.qWarning
self.qCritical = QtCore.qCritical
self.qFatal = QtCore.qFatal

if self.is_pyside:
self.Signal = QtCore.Signal
self.Slot = QtCore.Slot
self.Property = QtCore.Property
elif self.is_pyqt:
self.Signal = QtCore.pyqtSignal
self.Slot = QtCore.pyqtSlot
self.Property = QtCore.pyqtProperty
else:
assert False, "Expected either is_pyqt or is_pyside"

def _check_qt_api_version(self):
if not self.is_pyqt:
# We support all PySide versions
return

if self.QtCore.PYQT_VERSION == 0x060000: # 6.0.0
raise pytest.UsageError(
"PyQt 6.0 is not supported by pytest-qt, use 6.1+ instead."
)
elif self.QtCore.PYQT_VERSION < 0x050B00: # 5.11.0
raise pytest.UsageError(
"PyQt < 5.11 is not supported by pytest-qt, use 5.11+ instead."
)

def exec(self, obj, *args, **kwargs):
# exec was a keyword in Python 2, so PySide2 (and also PySide6 6.0)
# name the corresponding method "exec_" instead.
#
# The old _exec() alias is removed in PyQt6 and also deprecated as of
# PySide 6.1:
# https://codereview.qt-project.org/c/pyside/pyside-setup/+/342095
if hasattr(obj, "exec"):
return obj.exec(*args, **kwargs)
return obj.exec_(*args, **kwargs)

def get_versions(self):
if self.pytest_qt_api == "pyside6":
import PySide6

version = PySide6.__version__

return VersionTuple(
"PySide6", version, self.QtCore.qVersion(), self.QtCore.__version__
)
elif self.pytest_qt_api == "pyside2":
import PySide2

version = PySide2.__version__

return VersionTuple(
"PySide2", version, self.QtCore.qVersion(), self.QtCore.__version__
)
elif self.pytest_qt_api == "pyqt6":
return VersionTuple(
"PyQt6",
self.QtCore.PYQT_VERSION_STR,
self.QtCore.qVersion(),
self.QtCore.QT_VERSION_STR,
)
elif self.pytest_qt_api == "pyqt5":
return VersionTuple(
"PyQt5",
self.QtCore.PYQT_VERSION_STR,
self.QtCore.qVersion(),
self.QtCore.QT_VERSION_STR,
)

assert False, f"Internal error, unknown pytest_qt_api: {self.pytest_qt_api}"


qt_api = _QtApi()
is_pyqt = not is_pyside
2 changes: 1 addition & 1 deletion src/pytestqt/qtbot.py
Original file line number Diff line number Diff line change
Expand Up @@ -283,7 +283,7 @@ def stop(self):
if widget is not None:
widget_and_visibility.append((widget, widget.isVisible()))

qt_api.exec(qt_api.QtWidgets.QApplication.instance())
qt_api.QtWidgets.QApplication.instance().exec()

for widget, visible in widget_and_visibility:
widget.setVisible(visible)
Expand Down
4 changes: 2 additions & 2 deletions src/pytestqt/wait_signal.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ def wait(self):
self._timer.start()

if self.timeout != 0:
qt_api.exec(self._loop)
self._loop.exec()

if not self.signal_triggered and self.raising:
raise TimeoutError(self._timeout_message)
Expand Down Expand Up @@ -668,7 +668,7 @@ def wait(self):
if self._timer is not None:
self._timer.timeout.connect(self._quit_loop_by_timeout)
self._timer.start()
qt_api.exec(self._loop)
self._loop.exec()
if not self.called and self.raising:
raise TimeoutError("Callback wasn't called after %sms." % self.timeout)

Expand Down
18 changes: 9 additions & 9 deletions tests/test_basics.py
Original file line number Diff line number Diff line change
Expand Up @@ -446,7 +446,7 @@ def test_qt_api_ini_config(testdir, monkeypatch, option_api):
"""
from pytestqt.qt_compat import qt_api

monkeypatch.delenv("PYTEST_QT_API", raising=False)
monkeypatch.delenv("QT_API", raising=False)

testdir.makeini(
"""
Expand All @@ -467,7 +467,7 @@ def test_foo(qtbot):
)

result = testdir.runpytest_subprocess()
if qt_api.pytest_qt_api == option_api:
if qt_api.QT_API == option_api:
result.stdout.fnmatch_lines(["* 1 passed in *"])
else:
try:
Expand All @@ -492,7 +492,7 @@ def test_qt_api_ini_config_with_envvar(testdir, monkeypatch, envvar):
)
)

monkeypatch.setenv("PYTEST_QT_API", envvar)
monkeypatch.setenv("QT_API", envvar)

testdir.makepyfile(
"""
Expand All @@ -504,7 +504,7 @@ def test_foo(qtbot):
)

result = testdir.runpytest_subprocess()
if qt_api.pytest_qt_api == envvar:
if qt_api.QT_API == envvar:
result.stdout.fnmatch_lines(["* 1 passed in *"])
else:
try:
Expand All @@ -529,10 +529,10 @@ def test_foo(qtbot):
pass
"""
)
monkeypatch.setenv("PYTEST_QT_API", "piecute")
monkeypatch.setenv("QT_API", "piecute")
result = testdir.runpytest_subprocess()
result.stderr.fnmatch_lines(
["* Invalid value for $PYTEST_QT_API: piecute, expected one of *"]
["* Invalid value for $QT_API: piecute, expected one of *"]
)


Expand Down Expand Up @@ -566,7 +566,7 @@ def _fake_import(name, *args):
def _fake_is_library_loaded(name, *args):
return False

monkeypatch.delenv("PYTEST_QT_API", raising=False)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note that we need to keep supporting PYTEST_QT_API for backward compatibility, so we should not change these tests.

monkeypatch.delenv("QT_API", raising=False)
monkeypatch.setattr(qt_compat, "_import", _fake_import)
monkeypatch.setattr(qt_compat, "_is_library_loaded", _fake_is_library_loaded)

Expand Down Expand Up @@ -640,13 +640,13 @@ def _fake_import(name, *args, **kwargs):
def _fake_is_library_loaded(name, *args):
return name == backend

monkeypatch.delenv("PYTEST_QT_API", raising=False)
monkeypatch.delenv("QT_API", raising=False)
monkeypatch.setattr(qt_compat, "_is_library_loaded", _fake_is_library_loaded)
monkeypatch.setattr(builtins, "__import__", _fake_import)

qt_api.set_qt_api(api=None)

assert qt_api.pytest_qt_api == option_api
assert qt_api.QT_API == option_api


def test_before_close_func(testdir):
Expand Down
2 changes: 1 addition & 1 deletion tests/test_modeltest.py
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ def data(


xfail_py311_pyside2 = pytest.mark.xfail(
sys.version_info[:2] == (3, 11) and qt_api.pytest_qt_api == "pyside2",
sys.version_info[:2] == (3, 11) and qt_api.QT_API == "pyside2",
reason="Fails to OR mask flags",
)

Expand Down
4 changes: 2 additions & 2 deletions tests/test_wait_signal.py
Original file line number Diff line number Diff line change
Expand Up @@ -918,7 +918,7 @@ def test_empty_when_no_signal_name_available(self, qtbot, signaller):
Tests that all_signals_and_args is empty even though expected signals are emitted, but signal names aren't
available.
"""
if qt_api.pytest_qt_api != "pyside2":
if qt_api.QT_API != "pyside2":
pytest.skip(
"test only makes sense for PySide2, whose signals don't contain a name!"
)
Expand Down Expand Up @@ -1202,7 +1202,7 @@ def test_degenerate_error_msg(self, qtbot, signaller):
by the user. This degenerate messages doesn't contain the signals' names, and includes a hint to the user how
to fix the situation.
"""
if qt_api.pytest_qt_api != "pyside2":
if qt_api.QT_API != "pyside2":
pytest.skip(
"test only makes sense for PySide, whose signals don't contain a name!"
)
Expand Down
Loading