Skip to content

Commit 9af08db

Browse files
committed
FIX: account for PyQt6 API changes in 6.1 and set minimum version
We do not exercise all of the code paths that call _enum in the tests. At least PyQt5 5.8 does not support the enums by name so we still need the version gating.
1 parent a05a264 commit 9af08db

File tree

5 files changed

+181
-24
lines changed

5 files changed

+181
-24
lines changed

doc/devel/dependencies.rst

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ Matplotlib figures can be rendered to various user interfaces. See
4141
and the capabilities they provide.
4242

4343
* Tk_ (>= 8.3, != 8.6.0 or 8.6.1) [#]_: for the Tk-based backends.
44-
* PyQt6_, PySide6_, PyQt5_, or PySide2_: for the Qt-based backends.
44+
* PyQt6_ (>= 6.1), PySide6_, PyQt5_, or PySide2_: for the Qt-based backends.
4545
* PyGObject_: for the GTK3-based backends [#]_.
4646
* wxPython_ (>= 4) [#]_: for the wx-based backends.
4747
* pycairo_ (>= 1.11.0) or cairocffi_ (>= 0.8): for the GTK3 and/or cairo-based

lib/matplotlib/backends/backend_qt.py

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@
6969
# Elements are (Qt::KeyboardModifiers, Qt::Key) tuples.
7070
# Order determines the modifier order (ctrl+alt+...) reported by Matplotlib.
7171
_MODIFIER_KEYS = [
72-
(_to_int(getattr(_enum("QtCore.Qt.KeyboardModifiers"), mod)),
72+
(_to_int(getattr(_enum("QtCore.Qt.KeyboardModifier"), mod)),
7373
_to_int(getattr(_enum("QtCore.Qt.Key"), key)))
7474
for mod, key in [
7575
("ControlModifier", "Key_Control"),
@@ -210,7 +210,7 @@ class FigureCanvasQT(QtWidgets.QWidget, FigureCanvasBase):
210210
_timer_cls = TimerQT
211211

212212
buttond = {
213-
getattr(_enum("QtCore.Qt.MouseButtons"), k): v for k, v in [
213+
getattr(_enum("QtCore.Qt.MouseButton"), k): v for k, v in [
214214
("LeftButton", MouseButton.LEFT),
215215
("RightButton", MouseButton.RIGHT),
216216
("MiddleButton", MouseButton.MIDDLE),
@@ -633,8 +633,8 @@ def __init__(self, canvas, parent, coordinates=True):
633633
"""coordinates: should we show the coordinates on the right?"""
634634
QtWidgets.QToolBar.__init__(self, parent)
635635
self.setAllowedAreas(
636-
_enum("QtCore.Qt.ToolBarAreas").TopToolBarArea
637-
| _enum("QtCore.Qt.ToolBarAreas").TopToolBarArea)
636+
_enum("QtCore.Qt.ToolBarArea").TopToolBarArea
637+
| _enum("QtCore.Qt.ToolBarArea").TopToolBarArea)
638638

639639
self.coordinates = coordinates
640640
self._actions = {} # mapping of toolitem method names to QActions.
@@ -658,8 +658,8 @@ def __init__(self, canvas, parent, coordinates=True):
658658
if self.coordinates:
659659
self.locLabel = QtWidgets.QLabel("", self)
660660
self.locLabel.setAlignment(
661-
_enum("QtCore.Qt.Alignment").AlignRight
662-
| _enum("QtCore.Qt.Alignment").AlignVCenter)
661+
_enum("QtCore.Qt.AlignmentFlag").AlignRight
662+
| _enum("QtCore.Qt.AlignmentFlag").AlignVCenter)
663663
self.locLabel.setSizePolicy(QtWidgets.QSizePolicy(
664664
_enum("QtWidgets.QSizePolicy.Policy").Expanding,
665665
_enum("QtWidgets.QSizePolicy.Policy").Ignored,
@@ -890,12 +890,12 @@ def __init__(self, toolmanager, parent):
890890
ToolContainerBase.__init__(self, toolmanager)
891891
QtWidgets.QToolBar.__init__(self, parent)
892892
self.setAllowedAreas(
893-
_enum("QtCore.Qt.ToolBarAreas").TopToolBarArea
894-
| _enum("QtCore.Qt.ToolBarAreas").TopToolBarArea)
893+
_enum("QtCore.Qt.ToolBarArea").TopToolBarArea
894+
| _enum("QtCore.Qt.ToolBarArea").TopToolBarArea)
895895
message_label = QtWidgets.QLabel("")
896896
message_label.setAlignment(
897-
_enum("QtCore.Qt.Alignment").AlignRight
898-
| _enum("QtCore.Qt.Alignment").AlignVCenter)
897+
_enum("QtCore.Qt.AlignmentFlag").AlignRight
898+
| _enum("QtCore.Qt.AlignmentFlag").AlignVCenter)
899899
message_label.setSizePolicy(QtWidgets.QSizePolicy(
900900
_enum("QtWidgets.QSizePolicy.Policy").Expanding,
901901
_enum("QtWidgets.QSizePolicy.Policy").Ignored,

lib/matplotlib/backends/qt_compat.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -150,7 +150,7 @@ def _isdeleted(obj): return not shiboken2.isValid(obj)
150150
def _enum(name):
151151
# foo.bar.Enum.Entry (PyQt6) <=> foo.bar.Entry (non-PyQt6).
152152
return operator.attrgetter(
153-
name if QT_API == "PyQt6" else name.rpartition(".")[0]
153+
name if QT_API == 'PyQt6' else name.rpartition(".")[0]
154154
)(sys.modules[QtCore.__package__])
155155

156156

lib/matplotlib/backends/qt_editor/_formlayout.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -441,12 +441,12 @@ def __init__(self, data, title="", comment="",
441441

442442
# Button box
443443
self.bbox = bbox = QtWidgets.QDialogButtonBox(
444-
_enum("QtWidgets.QDialogButtonBox.StandardButtons").Ok
445-
| _enum("QtWidgets.QDialogButtonBox.StandardButtons").Cancel)
444+
_enum("QtWidgets.QDialogButtonBox.StandardButton").Ok
445+
| _enum("QtWidgets.QDialogButtonBox.StandardButton").Cancel)
446446
self.formwidget.update_buttons.connect(self.update_buttons)
447447
if self.apply_callback is not None:
448448
apply_btn = bbox.addButton(
449-
_enum("QtWidgets.QDialogButtonBox.StandardButtons").Apply)
449+
_enum("QtWidgets.QDialogButtonBox.StandardButton").Apply)
450450
apply_btn.clicked.connect(self.apply)
451451

452452
bbox.accepted.connect(self.accept)
@@ -471,7 +471,7 @@ def update_buttons(self):
471471
valid = False
472472
for btn_type in ["Ok", "Apply"]:
473473
btn = self.bbox.button(
474-
getattr(_enum("QtWidgets.QDialogButtonBox.StandardButtons"),
474+
getattr(_enum("QtWidgets.QDialogButtonBox.StandardButton"),
475475
btn_type))
476476
if btn is not None:
477477
btn.setEnabled(valid)

lib/matplotlib/tests/test_backend_qt.py

Lines changed: 165 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,20 @@
11
import copy
2-
from datetime import date, datetime
2+
import importlib
3+
import inspect
4+
import os
35
import signal
6+
import subprocess
7+
import sys
8+
9+
from datetime import date, datetime
410
from unittest import mock
511

12+
import pytest
13+
614
import matplotlib
715
from matplotlib import pyplot as plt
816
from matplotlib._pylab_helpers import Gcf
9-
10-
import pytest
17+
from matplotlib import _c_internal_utils
1118

1219

1320
try:
@@ -143,9 +150,9 @@ def test_correct_key(backend, qt_core, qt_key, qt_mods, answer):
143150
Assert sent and caught keys are the same.
144151
"""
145152
from matplotlib.backends.qt_compat import _enum, _to_int
146-
qt_mod = _enum("QtCore.Qt.KeyboardModifiers").NoModifier
153+
qt_mod = _enum("QtCore.Qt.KeyboardModifier").NoModifier
147154
for mod in qt_mods:
148-
qt_mod |= getattr(_enum("QtCore.Qt.KeyboardModifiers"), mod)
155+
qt_mod |= getattr(_enum("QtCore.Qt.KeyboardModifier"), mod)
149156

150157
class _Event:
151158
def isAutoRepeat(self): return False
@@ -258,9 +265,7 @@ def test_figureoptions_with_datetime_axes():
258265
datetime(year=2021, month=2, day=1)
259266
]
260267
ax.plot(xydata, xydata)
261-
with mock.patch(
262-
"matplotlib.backends.qt_editor._formlayout.FormDialog.exec",
263-
lambda self: None):
268+
with mock.patch("matplotlib.backends.qt_compat._exec", lambda obj: None):
264269
fig.canvas.manager.toolbar.edit_parameters()
265270

266271

@@ -318,3 +323,155 @@ def test_form_widget_get_with_datetime_and_date_fields():
318323
datetime(year=2021, month=3, day=11),
319324
date(year=2021, month=3, day=11)
320325
]
326+
327+
328+
# The source of this function gets extracted and run in another process, so it
329+
# must be fully self-contained.
330+
def _test_enums_impl():
331+
import sys
332+
333+
from matplotlib.backends.qt_compat import _enum, _to_int, QtCore
334+
from matplotlib.backend_bases import cursors, MouseButton
335+
336+
_enum("QtGui.QDoubleValidator.State").Acceptable
337+
338+
_enum("QtWidgets.QDialogButtonBox.StandardButton").Ok
339+
_enum("QtWidgets.QDialogButtonBox.StandardButton").Cancel
340+
_enum("QtWidgets.QDialogButtonBox.StandardButton").Apply
341+
for btn_type in ["Ok", "Cancel"]:
342+
getattr(_enum("QtWidgets.QDialogButtonBox.StandardButton"), btn_type)
343+
344+
_enum("QtGui.QImage.Format").Format_ARGB32_Premultiplied
345+
_enum("QtGui.QImage.Format").Format_ARGB32_Premultiplied
346+
# SPECIAL_KEYS are Qt::Key that do *not* return their unicode name instead
347+
# they have manually specified names.
348+
SPECIAL_KEYS = {
349+
_to_int(getattr(_enum("QtCore.Qt.Key"), k)): v
350+
for k, v in [
351+
("Key_Escape", "escape"),
352+
("Key_Tab", "tab"),
353+
("Key_Backspace", "backspace"),
354+
("Key_Return", "enter"),
355+
("Key_Enter", "enter"),
356+
("Key_Insert", "insert"),
357+
("Key_Delete", "delete"),
358+
("Key_Pause", "pause"),
359+
("Key_SysReq", "sysreq"),
360+
("Key_Clear", "clear"),
361+
("Key_Home", "home"),
362+
("Key_End", "end"),
363+
("Key_Left", "left"),
364+
("Key_Up", "up"),
365+
("Key_Right", "right"),
366+
("Key_Down", "down"),
367+
("Key_PageUp", "pageup"),
368+
("Key_PageDown", "pagedown"),
369+
("Key_Shift", "shift"),
370+
# In OSX, the control and super (aka cmd/apple) keys are switched.
371+
("Key_Control", "control" if sys.platform != "darwin" else "cmd"),
372+
("Key_Meta", "meta" if sys.platform != "darwin" else "control"),
373+
("Key_Alt", "alt"),
374+
("Key_CapsLock", "caps_lock"),
375+
("Key_F1", "f1"),
376+
("Key_F2", "f2"),
377+
("Key_F3", "f3"),
378+
("Key_F4", "f4"),
379+
("Key_F5", "f5"),
380+
("Key_F6", "f6"),
381+
("Key_F7", "f7"),
382+
("Key_F8", "f8"),
383+
("Key_F9", "f9"),
384+
("Key_F10", "f10"),
385+
("Key_F10", "f11"),
386+
("Key_F12", "f12"),
387+
("Key_Super_L", "super"),
388+
("Key_Super_R", "super"),
389+
]
390+
}
391+
# Define which modifier keys are collected on keyboard events. Elements
392+
# are (Qt::KeyboardModifiers, Qt::Key) tuples. Order determines the
393+
# modifier order (ctrl+alt+...) reported by Matplotlib.
394+
_MODIFIER_KEYS = [
395+
(
396+
_to_int(getattr(_enum("QtCore.Qt.KeyboardModifier"), mod)),
397+
_to_int(getattr(_enum("QtCore.Qt.Key"), key)),
398+
)
399+
for mod, key in [
400+
("ControlModifier", "Key_Control"),
401+
("AltModifier", "Key_Alt"),
402+
("ShiftModifier", "Key_Shift"),
403+
("MetaModifier", "Key_Meta"),
404+
]
405+
]
406+
cursord = {
407+
k: getattr(_enum("QtCore.Qt.CursorShape"), v)
408+
for k, v in [
409+
(cursors.MOVE, "SizeAllCursor"),
410+
(cursors.HAND, "PointingHandCursor"),
411+
(cursors.POINTER, "ArrowCursor"),
412+
(cursors.SELECT_REGION, "CrossCursor"),
413+
(cursors.WAIT, "WaitCursor"),
414+
]
415+
}
416+
417+
buttond = {
418+
getattr(_enum("QtCore.Qt.MouseButton"), k): v
419+
for k, v in [
420+
("LeftButton", MouseButton.LEFT),
421+
("RightButton", MouseButton.RIGHT),
422+
("MiddleButton", MouseButton.MIDDLE),
423+
("XButton1", MouseButton.BACK),
424+
("XButton2", MouseButton.FORWARD),
425+
]
426+
}
427+
428+
_enum("QtCore.Qt.WidgetAttribute").WA_OpaquePaintEvent
429+
_enum("QtCore.Qt.FocusPolicy").StrongFocus
430+
_enum("QtCore.Qt.ToolBarArea").TopToolBarArea
431+
_enum("QtCore.Qt.ToolBarArea").TopToolBarArea
432+
_enum("QtCore.Qt.AlignmentFlag").AlignRight
433+
_enum("QtCore.Qt.AlignmentFlag").AlignVCenter
434+
_enum("QtWidgets.QSizePolicy.Policy").Expanding
435+
_enum("QtWidgets.QSizePolicy.Policy").Ignored
436+
_enum("QtCore.Qt.MaskMode").MaskOutColor
437+
_enum("QtCore.Qt.ToolBarArea").TopToolBarArea
438+
_enum("QtCore.Qt.ToolBarArea").TopToolBarArea
439+
_enum("QtCore.Qt.AlignmentFlag").AlignRight
440+
_enum("QtCore.Qt.AlignmentFlag").AlignVCenter
441+
_enum("QtWidgets.QSizePolicy.Policy").Expanding
442+
_enum("QtWidgets.QSizePolicy.Policy").Ignored
443+
444+
445+
def _get_testable_qt_backends():
446+
envs = []
447+
for deps, env in [
448+
([qt_api], {"MPLBACKEND": "qtagg", "QT_API": qt_api})
449+
for qt_api in ["PyQt6", "PySide6", "PyQt5", "PySide2"]
450+
]:
451+
reason = None
452+
missing = [dep for dep in deps if not importlib.util.find_spec(dep)]
453+
if (sys.platform == "linux" and
454+
not _c_internal_utils.display_is_valid()):
455+
reason = "$DISPLAY and $WAYLAND_DISPLAY are unset"
456+
elif missing:
457+
reason = "{} cannot be imported".format(", ".join(missing))
458+
elif env["MPLBACKEND"] == 'macosx' and os.environ.get('TF_BUILD'):
459+
reason = "macosx backend fails on Azure"
460+
marks = []
461+
if reason:
462+
marks.append(pytest.mark.skip(
463+
reason=f"Skipping {env} because {reason}"))
464+
envs.append(pytest.param(env, marks=marks, id=str(env)))
465+
return envs
466+
467+
_test_timeout = 10 # Empirically, 1s is not enough on CI.
468+
469+
470+
@pytest.mark.parametrize("env", _get_testable_qt_backends())
471+
def test_enums_available(env):
472+
proc = subprocess.run(
473+
[sys.executable, "-c",
474+
inspect.getsource(_test_enums_impl) + "\n_test_enums_impl()"],
475+
env={**os.environ, "SOURCE_DATE_EPOCH": "0", **env},
476+
timeout=_test_timeout, check=True,
477+
stdout=subprocess.PIPE, universal_newlines=True)

0 commit comments

Comments
 (0)