Skip to content

Commit 0927491

Browse files
authored
Merge pull request #164 from CabbageDevelopment/type-checking-imports
Add TYPE_CHECKING for runtime Qt imports
2 parents 1ec3d80 + aed428d commit 0927491

2 files changed

Lines changed: 128 additions & 67 deletions

File tree

src/qasync/__init__.py

Lines changed: 46 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -22,84 +22,62 @@
2222
import time
2323
from concurrent.futures import Future
2424
from queue import Queue
25+
from typing import TYPE_CHECKING, Literal, Tuple, cast, get_args
2526

2627
logger = logging.getLogger(__name__)
2728

28-
QtModule = None
29-
30-
# If QT_API env variable is given, use that or fail trying
31-
qtapi_env = os.getenv("QT_API", "").strip().lower()
32-
if qtapi_env:
33-
env_to_mod_map = {
34-
"pyqt5": "PyQt5",
35-
"pyqt6": "PyQt6",
36-
"pyqt": "PyQt6",
37-
"pyside6": "PySide6",
38-
"pyside2": "PySide2",
39-
"pyside": "PySide6",
40-
}
41-
if qtapi_env in env_to_mod_map:
42-
QtModuleName = env_to_mod_map[qtapi_env]
43-
else:
44-
raise ImportError(
45-
"QT_API environment variable set ({}) but not one of [{}].".format(
46-
qtapi_env, ", ".join(env_to_mod_map.keys())
47-
)
48-
)
49-
50-
logger.info("Forcing use of {} as Qt Implementation".format(QtModuleName))
51-
QtModule = importlib.import_module(QtModuleName)
29+
# runtime preference order is the same as this literal
30+
QtFlavor = Literal["PyQt6", "PyQt5", "PySide6", "PySide2"]
31+
QT_ALL = cast(Tuple[QtFlavor, ...], get_args(QtFlavor))
5232

53-
# If a Qt lib is already imported, use that
54-
if not QtModule:
55-
for QtModuleName in ("PyQt5", "PyQt6", "PySide2", "PySide6"):
56-
if QtModuleName in sys.modules:
57-
QtModule = sys.modules[QtModuleName]
58-
break
5933

60-
# Try importing qt libs
61-
if not QtModule:
62-
for QtModuleName in ("PyQt5", "PyQt6", "PySide2", "PySide6"):
34+
def _get_qt_flavor() -> QtFlavor:
35+
env = os.getenv("QT_API", "").strip().lower()
36+
# prioritize env var
37+
if env:
38+
lookup = {name.lower(): name for name in QT_ALL}
6339
try:
64-
QtModule = importlib.import_module(QtModuleName)
40+
name = lookup[env]
41+
except KeyError as err:
42+
raise ImportError(
43+
f"QT_API={env!r} is not one of {', '.join(QT_ALL)}"
44+
) from err
45+
logger.info("Forcing use of %s as Qt implementation", name)
46+
return cast(QtFlavor, name)
47+
# if already imported, use it
48+
for name in QT_ALL:
49+
if name in sys.modules:
50+
return cast(QtFlavor, name)
51+
# use the first available on system
52+
for name in QT_ALL:
53+
try:
54+
importlib.import_module(name)
55+
return cast(QtFlavor, name)
6556
except ImportError:
6657
continue
67-
else:
68-
break
69-
70-
if not QtModule:
7158
raise ImportError("No Qt implementations found")
7259

73-
QtCore = importlib.import_module(QtModuleName + ".QtCore", package=QtModuleName)
74-
QtGui = importlib.import_module(QtModuleName + ".QtGui", package=QtModuleName)
75-
76-
if QtModuleName == "PyQt5":
77-
from PyQt5 import QtWidgets
78-
from PyQt5.QtCore import pyqtSlot as Slot
79-
80-
QApplication = QtWidgets.QApplication
81-
AllEvents = QtCore.QEventLoop.ProcessEventsFlags(0x00)
8260

83-
elif QtModuleName == "PyQt6":
84-
from PyQt6 import QtWidgets
85-
from PyQt6.QtCore import pyqtSlot as Slot
61+
if TYPE_CHECKING:
62+
from PySide6 import QtCore, QtWidgets
63+
from PySide6.QtCore import Slot
8664

8765
QApplication = QtWidgets.QApplication
8866
AllEvents = QtCore.QEventLoop.ProcessEventsFlag(0x00)
89-
90-
elif QtModuleName == "PySide2":
91-
from PySide2 import QtWidgets
92-
from PySide2.QtCore import Slot
93-
67+
else:
68+
qt_flavor = _get_qt_flavor()
69+
QtCore = importlib.import_module(f"{qt_flavor}.QtCore")
70+
QtWidgets = importlib.import_module(f"{qt_flavor}.QtWidgets")
9471
QApplication = QtWidgets.QApplication
95-
AllEvents = QtCore.QEventLoop.ProcessEventsFlags(0x00)
9672

97-
elif QtModuleName == "PySide6":
98-
from PySide6 import QtWidgets
99-
from PySide6.QtCore import Slot
73+
# PyQt uses pyqtSlot, PySide uses Slot
74+
Slot = getattr(QtCore, "pyqtSlot", None) or getattr(QtCore, "Slot", None)
10075

101-
QApplication = QtWidgets.QApplication
102-
AllEvents = QtCore.QEventLoop.ProcessEventsFlags(0x00)
76+
# PyQt6 uses ProcessEventsFlags, others use ProcessEventsFlag
77+
Flags = getattr(QtCore.QEventLoop, "ProcessEventsFlags", None) or getattr(
78+
QtCore.QEventLoop, "ProcessEventsFlag"
79+
)
80+
AllEvents = Flags(0x00)
10381

10482
from ._common import with_logger # noqa
10583

@@ -828,16 +806,17 @@ def __log_error(cls, *args, **kwds):
828806
sys.stderr.write("{!r}, {!r}\n".format(args, kwds))
829807

830808

831-
from ._unix import _SelectorEventLoop # noqa
832-
833-
QSelectorEventLoop = type("QSelectorEventLoop", (_QEventLoop, _SelectorEventLoop), {})
809+
if sys.platform == "win32":
810+
from ._windows import _ProactorEventLoop # noqa: F401
834811

835-
if os.name == "nt":
836-
from ._windows import _ProactorEventLoop
812+
class QIOCPEventLoop(_QEventLoop, _ProactorEventLoop): ...
837813

838-
QIOCPEventLoop = type("QIOCPEventLoop", (_QEventLoop, _ProactorEventLoop), {})
839814
QEventLoop = QIOCPEventLoop
840815
else:
816+
from ._unix import _SelectorEventLoop # noqa: F401
817+
818+
class QSelectorEventLoop(_QEventLoop, _SelectorEventLoop): ...
819+
841820
QEventLoop = QSelectorEventLoop
842821

843822

tests/test_environment.py

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
"""
2+
BSD License
3+
"""
4+
5+
import importlib
6+
import sys
7+
import types
8+
9+
import pytest
10+
from pytest import MonkeyPatch
11+
12+
from qasync import QT_ALL, _get_qt_flavor
13+
14+
15+
def _purge_qt(mp: MonkeyPatch):
16+
"""Ensure no Qt modules are loaded."""
17+
for name in QT_ALL:
18+
mp.delitem(sys.modules, name, raising=False)
19+
20+
21+
def _stub_import(mp: MonkeyPatch, available=()):
22+
"""Patch importlib.import_module to only 'exist' for certain modules."""
23+
24+
def fake_import(name):
25+
if name in available:
26+
return types.ModuleType(name)
27+
raise ImportError
28+
29+
mp.setattr(importlib, "import_module", fake_import)
30+
31+
32+
def test_env_exact():
33+
with MonkeyPatch.context() as mp:
34+
_purge_qt(mp)
35+
_stub_import(mp)
36+
mp.setenv("QT_API", "PySide6")
37+
assert _get_qt_flavor() == "PySide6"
38+
39+
40+
def test_env_invalid_raises():
41+
with MonkeyPatch.context() as mp:
42+
_purge_qt(mp)
43+
_stub_import(mp)
44+
mp.setenv("QT_API", "QT")
45+
with pytest.raises(ImportError):
46+
_get_qt_flavor()
47+
48+
49+
def test_already_imported_precedence():
50+
with MonkeyPatch.context() as mp:
51+
_purge_qt(mp)
52+
_stub_import(mp)
53+
mp.delenv("QT_API", raising=False)
54+
mp.setitem(sys.modules, "PySide2", types.ModuleType("PySide2"))
55+
mp.setitem(sys.modules, "PyQt5", types.ModuleType("PyQt5"))
56+
assert _get_qt_flavor() == next(n for n in QT_ALL if n in ("PyQt5", "PySide2"))
57+
58+
59+
def test_first_available_import():
60+
with MonkeyPatch.context() as mp:
61+
_purge_qt(mp)
62+
_stub_import(mp, available=("PySide6",))
63+
mp.delenv("QT_API", raising=False)
64+
assert _get_qt_flavor() == "PySide6"
65+
66+
67+
def test_none_available_raises():
68+
with MonkeyPatch.context() as mp:
69+
_purge_qt(mp)
70+
_stub_import(mp)
71+
mp.delenv("QT_API", raising=False)
72+
with pytest.raises(ImportError):
73+
_get_qt_flavor()
74+
75+
76+
def test_env_overrides_imported():
77+
with MonkeyPatch.context() as mp:
78+
_purge_qt(mp)
79+
_stub_import(mp)
80+
mp.setitem(sys.modules, "PyQt6", types.ModuleType("PyQt6"))
81+
mp.setenv("QT_API", "PySide2")
82+
assert _get_qt_flavor() == "PySide2"

0 commit comments

Comments
 (0)