Skip to content

Commit 5b6ed5c

Browse files
Select Qt backend if already imported
Co-authored-by: Bruno Oliveira <[email protected]>
1 parent 03b2f82 commit 5b6ed5c

File tree

4 files changed

+112
-27
lines changed

4 files changed

+112
-27
lines changed

CHANGELOG.rst

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,12 @@ UNRELEASED
44
- Use ``pytest.hookimpl`` to configure hooks, avoiding a deprecation warning in
55
the upcoming pytest 7.2.0.
66

7+
- Now ``pytest-qt`` will check if any of the Qt libraries is already imported by the time the plugin loads,
8+
and use it if that is the case (`#412`_). Thanks `@eyllanesc`_ for the PR.
9+
10+
.. _#412: https://github.com/pytest-dev/pytest-qt/pull/412
11+
.. _@eyllanesc: https://github.com/eyllanesc
12+
713
4.1.0 (2022-06-23)
814
------------------
915

README.rst

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -76,9 +76,11 @@ Requirements
7676

7777
Since version 4.1.0, ``pytest-qt`` requires Python 3.7+.
7878

79-
Works with either PySide6_, PySide2_, PyQt6_ or PyQt5_, picking whichever
80-
is available on the system, giving preference to the first one installed in
81-
this order:
79+
Works with either PySide6_, PySide2_, PyQt6_ or PyQt5_.
80+
81+
If any of the above libraries is already imported by the time the tests execute, that library will be used.
82+
83+
If not, pytest-qt will try to import and use the Qt APIs, in this order:
8284

8385
- ``PySide6``
8486
- ``PySide2``

src/pytestqt/qt_compat.py

Lines changed: 30 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"""
22
Provide a common way to import Qt classes used by pytest-qt in a unique manner,
3-
abstracting API differences between PyQt5 and PySide2/6.
3+
abstracting API differences between PyQt5/6 and PySide2/6.
44
55
.. note:: This module is not part of pytest-qt public API, hence its interface
66
may change between releases and users should not rely on it.
@@ -9,20 +9,31 @@
99
"""
1010

1111

12-
from collections import namedtuple
12+
from collections import namedtuple, OrderedDict
1313
import os
14+
import sys
1415

1516
import pytest
1617

1718

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

21+
QT_APIS = OrderedDict()
22+
QT_APIS["pyside6"] = "PySide6"
23+
QT_APIS["pyside2"] = "PySide2"
24+
QT_APIS["pyqt6"] = "PyQt6"
25+
QT_APIS["pyqt5"] = "PyQt5"
26+
2027

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

2532

33+
def _is_library_loaded(name):
34+
return name in sys.modules
35+
36+
2637
class _QtApi:
2738
"""
2839
Interface to the underlying Qt API currently configured for pytest-qt.
@@ -36,12 +47,7 @@ def __init__(self):
3647

3748
def _get_qt_api_from_env(self):
3849
api = os.environ.get("PYTEST_QT_API")
39-
supported_apis = [
40-
"pyside6",
41-
"pyside2",
42-
"pyqt6",
43-
"pyqt5",
44-
]
50+
supported_apis = QT_APIS.keys()
4551

4652
if api is not None:
4753
api = api.lower()
@@ -50,6 +56,12 @@ def _get_qt_api_from_env(self):
5056
raise pytest.UsageError(msg)
5157
return api
5258

59+
def _get_already_loaded_backend(self):
60+
for api, backend in QT_APIS.items():
61+
if _is_library_loaded(backend):
62+
return api
63+
return None
64+
5365
def _guess_qt_api(self): # pragma: no cover
5466
def _can_import(name):
5567
try:
@@ -61,18 +73,18 @@ def _can_import(name):
6173

6274
# Note, not importing only the root namespace because when uninstalling from conda,
6375
# the namespace can still be there.
64-
if _can_import("PySide6.QtCore"):
65-
return "pyside6"
66-
elif _can_import("PySide2.QtCore"):
67-
return "pyside2"
68-
elif _can_import("PyQt6.QtCore"):
69-
return "pyqt6"
70-
elif _can_import("PyQt5.QtCore"):
71-
return "pyqt5"
76+
for api, backend in QT_APIS.items():
77+
if _can_import(f"{backend}.QtCore"):
78+
return api
7279
return None
7380

7481
def set_qt_api(self, api):
75-
self.pytest_qt_api = self._get_qt_api_from_env() or api or self._guess_qt_api()
82+
self.pytest_qt_api = (
83+
self._get_qt_api_from_env()
84+
or api
85+
or self._get_already_loaded_backend()
86+
or self._guess_qt_api()
87+
)
7688

7789
self.is_pyside = self.pytest_qt_api in ["pyside2", "pyside6"]
7890
self.is_pyqt = self.pytest_qt_api in ["pyqt5", "pyqt6"]
@@ -88,13 +100,7 @@ def set_qt_api(self, api):
88100
)
89101
raise pytest.UsageError(msg)
90102

91-
_root_modules = {
92-
"pyside6": "PySide6",
93-
"pyside2": "PySide2",
94-
"pyqt6": "PyQt6",
95-
"pyqt5": "PyQt5",
96-
}
97-
_root_module = _root_modules[self.pytest_qt_api]
103+
_root_module = QT_APIS[self.pytest_qt_api]
98104

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

tests/test_basics.py

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -563,8 +563,12 @@ def test_importerror(monkeypatch):
563563
def _fake_import(name, *args):
564564
raise ModuleNotFoundError(f"Failed to import {name}")
565565

566+
def _fake_is_library_loaded(name, *args):
567+
return False
568+
566569
monkeypatch.delenv("PYTEST_QT_API", raising=False)
567570
monkeypatch.setattr(qt_compat, "_import", _fake_import)
571+
monkeypatch.setattr(qt_compat, "_is_library_loaded", _fake_is_library_loaded)
568572

569573
expected = (
570574
"pytest-qt requires either PySide2, PySide6, PyQt5 or PyQt6 installed.\n"
@@ -578,6 +582,73 @@ def _fake_import(name, *args):
578582
qt_api.set_qt_api(api=None)
579583

580584

585+
@pytest.mark.parametrize(
586+
"option_api, backend",
587+
[
588+
("pyqt5", "PyQt5"),
589+
("pyqt6", "PyQt6"),
590+
("pyside2", "PySide2"),
591+
("pyside6", "PySide6"),
592+
],
593+
)
594+
def test_already_loaded_backend(monkeypatch, option_api, backend):
595+
596+
import builtins
597+
598+
class Mock:
599+
pass
600+
601+
qtcore = Mock()
602+
for method_name in (
603+
"qInstallMessageHandler",
604+
"qDebug",
605+
"qWarning",
606+
"qCritical",
607+
"qFatal",
608+
):
609+
setattr(qtcore, method_name, lambda *_: None)
610+
611+
if backend in ("PyQt5", "PyQt6"):
612+
pyqt_version = 0x050B00 if backend == "PyQt5" else 0x060000
613+
qtcore.PYQT_VERSION = pyqt_version + 1
614+
qtcore.pyqtSignal = object()
615+
qtcore.pyqtSlot = object()
616+
qtcore.pyqtProperty = object()
617+
else:
618+
qtcore.Signal = object()
619+
qtcore.Slot = object()
620+
qtcore.Property = object()
621+
622+
qtwidgets = Mock()
623+
qapplication = Mock()
624+
qapplication.instance = lambda *_: None
625+
qtwidgets.QApplication = qapplication
626+
627+
qbackend = Mock()
628+
qbackend.QtCore = qtcore
629+
qbackend.QtGui = object()
630+
qbackend.QtTest = object()
631+
qbackend.QtWidgets = qtwidgets
632+
633+
import_orig = builtins.__import__
634+
635+
def _fake_import(name, *args, **kwargs):
636+
if name == backend:
637+
return qbackend
638+
return import_orig(name, *args, **kwargs)
639+
640+
def _fake_is_library_loaded(name, *args):
641+
return name == backend
642+
643+
monkeypatch.delenv("PYTEST_QT_API", raising=False)
644+
monkeypatch.setattr(qt_compat, "_is_library_loaded", _fake_is_library_loaded)
645+
monkeypatch.setattr(builtins, "__import__", _fake_import)
646+
647+
qt_api.set_qt_api(api=None)
648+
649+
assert qt_api.pytest_qt_api == option_api
650+
651+
581652
def test_before_close_func(testdir):
582653
"""
583654
Test the `before_close_func` argument of qtbot.addWidget.

0 commit comments

Comments
 (0)