Skip to content

Commit df95964

Browse files
authored
PR: Improve import modularity between QtGui, QtWidgets and QtOpenGL* related modules (#387)
2 parents f1d56a3 + 189f2c1 commit df95964

File tree

8 files changed

+213
-6
lines changed

8 files changed

+213
-6
lines changed

qtpy/QtGui.py

Lines changed: 57 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,35 @@
88

99
"""Provides QtGui classes and functions."""
1010

11-
from . import PYQT6, PYQT5, PYSIDE2, PYSIDE6
11+
from . import PYQT6, PYQT5, PYSIDE2, PYSIDE6, QtModuleNotInstalledError
12+
from .utils import getattr_missing_optional_dep
13+
14+
15+
_missing_optional_names = {}
16+
17+
_QTOPENGL_NAMES = {
18+
'QOpenGLBuffer',
19+
'QOpenGLContext',
20+
'QOpenGLContextGroup',
21+
'QOpenGLDebugLogger',
22+
'QOpenGLDebugMessage',
23+
'QOpenGLFramebufferObject',
24+
'QOpenGLFramebufferObjectFormat',
25+
'QOpenGLPixelTransferOptions',
26+
'QOpenGLShader',
27+
'QOpenGLShaderProgram',
28+
'QOpenGLTexture',
29+
'QOpenGLTextureBlitter',
30+
'QOpenGLVersionProfile',
31+
'QOpenGLVertexArrayObject',
32+
'QOpenGLWindow',
33+
}
34+
35+
def __getattr__(name):
36+
"""Custom getattr to chain and wrap errors due to missing optional deps."""
37+
raise getattr_missing_optional_dep(
38+
name, module_name=__name__, optional_names=_missing_optional_names)
39+
1240

1341
if PYQT5:
1442
from PyQt5.QtGui import *
@@ -18,7 +46,20 @@
1846
elif PYQT6:
1947
from PyQt6 import QtGui
2048
from PyQt6.QtGui import *
21-
from PyQt6.QtOpenGL import *
49+
50+
# Attempt to import QOpenGL* classes, but if that fails,
51+
# don't raise an exception until the name is explicitly accessed.
52+
# See https://github.com/spyder-ide/qtpy/pull/387/
53+
try:
54+
from PyQt6.QtOpenGL import *
55+
except ImportError as error:
56+
for name in _QTOPENGL_NAMES:
57+
_missing_optional_names[name] = {
58+
'name': 'PyQt6.QtOpenGL',
59+
'missing_package': 'pyopengl',
60+
'import_error': error,
61+
}
62+
2263
QFontMetrics.width = lambda self, *args, **kwargs: self.horizontalAdvance(*args, **kwargs)
2364
QFontMetricsF.width = lambda self, *args, **kwargs: self.horizontalAdvance(*args, **kwargs)
2465

@@ -42,7 +83,19 @@
4283
QFontMetrics.width = lambda self, *args, **kwargs: self.horizontalAdvance(*args, **kwargs)
4384
elif PYSIDE6:
4485
from PySide6.QtGui import *
45-
from PySide6.QtOpenGL import *
86+
87+
# Attempt to import QOpenGL* classes, but if that fails,
88+
# don't raise an exception until the name is explicitly accessed.
89+
# See https://github.com/spyder-ide/qtpy/pull/387/
90+
try:
91+
from PySide6.QtOpenGL import *
92+
except ImportError as error:
93+
for name in _QTOPENGL_NAMES:
94+
_missing_optional_names[name] = {
95+
'name': 'PySide6.QtOpenGL',
96+
'missing_package': 'pyopengl',
97+
'import_error': error,
98+
}
4699

47100
# Backport `QFileSystemModel` moved to QtGui in Qt6
48101
from PySide6.QtWidgets import QFileSystemModel
@@ -121,3 +174,4 @@ def movePositionPatched(
121174
QSinglePointEvent.globalPos = lambda self: self.globalPosition().toPoint()
122175
QSinglePointEvent.globalX = lambda self: self.globalPosition().toPoint().x()
123176
QSinglePointEvent.globalY = lambda self: self.globalPosition().toPoint().y()
177+

qtpy/QtWidgets.py

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,15 +8,36 @@
88

99
"""Provides widget classes and functions."""
1010

11-
from . import PYQT5, PYQT6, PYSIDE2, PYSIDE6
11+
from . import PYQT5, PYQT6, PYSIDE2, PYSIDE6, QtModuleNotInstalledError
12+
from .utils import getattr_missing_optional_dep
13+
14+
15+
_missing_optional_names = {}
16+
17+
def __getattr__(name):
18+
"""Custom getattr to chain and wrap errors due to missing optional deps."""
19+
raise getattr_missing_optional_dep(
20+
name, module_name=__name__, optional_names=_missing_optional_names)
21+
1222

1323
if PYQT5:
1424
from PyQt5.QtWidgets import *
1525
elif PYQT6:
1626
from PyQt6 import QtWidgets
1727
from PyQt6.QtWidgets import *
1828
from PyQt6.QtGui import QAction, QActionGroup, QShortcut, QFileSystemModel, QUndoCommand
19-
from PyQt6.QtOpenGLWidgets import QOpenGLWidget
29+
30+
# Attempt to import QOpenGLWidget, but if that fails,
31+
# don't raise an exception until the name is explicitly accessed.
32+
# See https://github.com/spyder-ide/qtpy/pull/387/
33+
try:
34+
from PyQt6.QtOpenGLWidgets import QOpenGLWidget
35+
except ImportError as error:
36+
_missing_optional_names['QOpenGLWidget'] = {
37+
'name': 'PyQt6.QtOpenGLWidgets',
38+
'missing_package': 'pyopengl',
39+
'import_error': error,
40+
}
2041

2142
# Map missing/renamed methods
2243
QTextEdit.setTabStopWidth = lambda self, *args, **kwargs: self.setTabStopDistance(*args, **kwargs)
@@ -39,7 +60,18 @@
3960
elif PYSIDE6:
4061
from PySide6.QtWidgets import *
4162
from PySide6.QtGui import QAction, QActionGroup, QShortcut, QUndoCommand
42-
from PySide6.QtOpenGLWidgets import QOpenGLWidget
63+
64+
# Attempt to import QOpenGLWidget, but if that fails,
65+
# don't raise an exception until the name is explicitly accessed.
66+
# See https://github.com/spyder-ide/qtpy/pull/387/
67+
try:
68+
from PySide6.QtOpenGLWidgets import QOpenGLWidget
69+
except ImportError as error:
70+
_missing_optional_names['QOpenGLWidget'] = {
71+
'name': 'PySide6.QtOpenGLWidgets',
72+
'missing_package': 'pyopengl',
73+
'import_error': error,
74+
}
4375

4476
# Map missing/renamed methods
4577
QTextEdit.setTabStopWidth = lambda self, *args, **kwargs: self.setTabStopDistance(*args, **kwargs)
@@ -52,3 +84,4 @@
5284
QApplication.exec_ = QApplication.exec
5385
QDialog.exec_ = lambda self, *args, **kwargs: self.exec(*args, **kwargs)
5486
QMenu.exec_ = lambda self, *args, **kwargs: self.exec(*args, **kwargs)
87+
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
"""Package used for testing the deferred import error mechanism."""
2+
3+
4+
# See https://github.com/spyder-ide/qtpy/pull/387/
5+
6+
7+
from qtpy.utils import getattr_missing_optional_dep
8+
from .optional_dep import ExampleClass
9+
10+
11+
_missing_optional_names = {}
12+
13+
14+
try:
15+
from .optional_dep import MissingClass
16+
except ImportError as error:
17+
_missing_optional_names['MissingClass'] = {
18+
'name': 'optional_dep.MissingClass',
19+
'missing_package': 'test_package_please_ignore',
20+
'import_error': error,
21+
}
22+
23+
24+
def __getattr__(name):
25+
"""Custom getattr to chain and wrap errors due to missing optional deps."""
26+
raise getattr_missing_optional_dep(
27+
name, module_name=__name__, optional_names=_missing_optional_names)
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
"""Test module with an optional dependency that may be missing."""
2+
3+
class ExampleClass:
4+
pass
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
"""Test the deferred import error mechanism"""
2+
3+
4+
# See https://github.com/spyder-ide/qtpy/pull/387/
5+
6+
7+
import pytest
8+
9+
from qtpy import QtModuleNotInstalledError
10+
11+
12+
def test_missing_optional_deps():
13+
"""Test importing a module that uses the deferred import error mechanism"""
14+
from . import optional_deps
15+
16+
assert optional_deps.ExampleClass is not None
17+
18+
with pytest.raises(QtModuleNotInstalledError) as excinfo:
19+
from .optional_deps import MissingClass
20+
21+
msg = 'The optional_dep.MissingClass module was not found. It must be installed separately as test_package_please_ignore.'
22+
assert msg == str(excinfo.value)

qtpy/tests/test_qtgui.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,3 +132,28 @@ def test_qtextcursor_moveposition():
132132
assert cursor.position() == cursor.anchor()
133133
assert cursor.movePosition(QtGui.QTextCursor.NextWord, QtGui.QTextCursor.KeepAnchor, 3)
134134
assert cursor.selectedText() == "foo bar baz"
135+
136+
137+
def test_opengl_imports():
138+
"""
139+
Test for presence of QOpenGL* classes.
140+
141+
These classes were members of QtGui in Qt5, but moved to QtOpenGL in Qt6.
142+
QtPy makes them available in QtGui to maintain compatibility.
143+
"""
144+
145+
assert QtGui.QOpenGLBuffer is not None
146+
assert QtGui.QOpenGLContext is not None
147+
assert QtGui.QOpenGLContextGroup is not None
148+
assert QtGui.QOpenGLDebugLogger is not None
149+
assert QtGui.QOpenGLDebugMessage is not None
150+
assert QtGui.QOpenGLFramebufferObject is not None
151+
assert QtGui.QOpenGLFramebufferObjectFormat is not None
152+
assert QtGui.QOpenGLPixelTransferOptions is not None
153+
assert QtGui.QOpenGLShader is not None
154+
assert QtGui.QOpenGLShaderProgram is not None
155+
assert QtGui.QOpenGLTexture is not None
156+
assert QtGui.QOpenGLTextureBlitter is not None
157+
assert QtGui.QOpenGLVersionProfile is not None
158+
assert QtGui.QOpenGLVertexArrayObject is not None
159+
assert QtGui.QOpenGLWindow is not None

qtpy/tests/test_qtwidgets.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,3 +116,13 @@ def test_enum_access():
116116
assert QtWidgets.QStyle.State_None == QtWidgets.QStyle.StateFlag.State_None
117117
assert QtWidgets.QSlider.TicksLeft == QtWidgets.QSlider.TickPosition.TicksAbove
118118
assert QtWidgets.QStyle.SC_SliderGroove == QtWidgets.QStyle.SubControl.SC_SliderGroove
119+
120+
121+
def test_opengl_imports():
122+
"""
123+
Test for presence of QOpenGLWidget.
124+
125+
QOpenGLWidget was a member of QtWidgets in Qt5, but moved to QtOpenGLWidgets in Qt6.
126+
QtPy makes QOpenGLWidget available in QtWidgets to maintain compatibility.
127+
"""
128+
assert QtWidgets.QOpenGLWidget is not None

qtpy/utils.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
# -----------------------------------------------------------------------------
2+
# Copyright © 2023- The Spyder Development Team
3+
#
4+
# Released under the terms of the MIT License
5+
# (see LICENSE.txt for details)
6+
# -----------------------------------------------------------------------------
7+
8+
"""Provides utility functions for use by QtPy itself."""
9+
10+
import qtpy
11+
12+
13+
def _wrap_missing_optional_dep_error(
14+
attr_error,
15+
*,
16+
import_error,
17+
wrapper=qtpy.QtModuleNotInstalledError,
18+
**wrapper_kwargs,
19+
):
20+
"""Create a __cause__-chained wrapper error for a missing optional dep."""
21+
qtpy_error = wrapper(**wrapper_kwargs)
22+
import_error.__cause__ = attr_error
23+
qtpy_error.__cause__ = import_error
24+
return qtpy_error
25+
26+
27+
def getattr_missing_optional_dep(name, module_name, optional_names):
28+
"""Wrap AttributeError in a special error if it matches."""
29+
attr_error = AttributeError(f'module {module_name!r} has no attribute {name!r}')
30+
if name in optional_names:
31+
return _wrap_missing_optional_dep_error(attr_error, **optional_names[name])
32+
return attr_error

0 commit comments

Comments
 (0)