From 2b165fa3e8cda33762c0c0cb2a72391ce910618e Mon Sep 17 00:00:00 2001 From: Corran Webster Date: Fri, 10 Mar 2023 07:56:19 +0000 Subject: [PATCH] Python 3.11 Support (#1210) This supports Python 3.11 with PySide 6.4 and PyQt 5. PySide 2 doesn't seem to be readily available for Python 3.11, so we're not testing with it. All of the changes for this still work with the existing set of tests for Python 3.8 and Python 3.10 with PySide2, PySide6 and PyQt5 on all platforms, and also with EDM on Python 3.8 I have manually checked that the examples work, modulo #1212 which is an orthogonal issue to this. So this is probably as good as it is going to get - we will likely find additional issues with the new Enums as we start to see real work done with it. As a side-effect, fixes #1202 --- .github/workflows/run-tests.yml | 5 +- pyface/ui/qt4/code_editor/code_widget.py | 54 +++++++++++++------ .../qt4/code_editor/tests/test_code_widget.py | 34 +++--------- .../ui/qt4/data_view/data_view_item_model.py | 2 +- pyface/ui/qt4/dialog.py | 12 ++--- .../ui/qt4/tasks/advanced_editor_area_pane.py | 2 +- pyface/ui/qt4/tasks/dock_pane.py | 8 +-- pyface/ui/qt4/tasks/task_window_backend.py | 2 +- pyproject.toml | 2 +- 9 files changed, 63 insertions(+), 58 deletions(-) diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 66495334a..65c9e636d 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -10,8 +10,11 @@ jobs: strategy: matrix: os: [ubuntu-latest, macos-latest, windows-latest] - python-version: ['3.8', '3.10'] + python-version: ['3.8', '3.10', '3.11'] qt-api: ['pyqt5', 'pyside2', 'pyside6'] + exclude: + - python-version: '3.11' + qt-api: 'pyside2' fail-fast: false env: diff --git a/pyface/ui/qt4/code_editor/code_widget.py b/pyface/ui/qt4/code_editor/code_widget.py index 3f114c3e1..132bdb765 100644 --- a/pyface/ui/qt4/code_editor/code_widget.py +++ b/pyface/ui/qt4/code_editor/code_widget.py @@ -11,9 +11,7 @@ import sys - -from pyface.qt import QtCore, QtGui - +from pyface.qt import QtCore, QtGui, is_qt5 from .find_widget import FindWidget from .gutters import LineNumberWidget, StatusGutterWidget @@ -21,6 +19,11 @@ from .pygments_highlighter import PygmentsHighlighter +def _exact_match(user_keys, key_sequence): + """Utility function for matching key sequences""" + return user_keys.matches(key_sequence) == QtGui.QKeySequence.SequenceMatch.ExactMatch # noqa: E501 + + class CodeWidget(QtGui.QPlainTextEdit): """ A widget for viewing and editing code. """ @@ -389,7 +392,10 @@ def keyPressEvent(self, event): if self.isReadOnly(): return super().keyPressEvent(event) - key_sequence = QtGui.QKeySequence(event.key() + int(event.modifiers())) + if is_qt5: + key_sequence = QtGui.QKeySequence(event.key() + int(event.modifiers())) + else: + key_sequence = QtGui.QKeySequence(event.keyCombination()) self.keyPressEvent_action(event) # FIXME: see above @@ -398,35 +404,42 @@ def keyPressEvent(self, event): # beginning of the document. Likewise, if the cursor is somewhere in the # last line, the "down" key causes it to go to the end. cursor = self.textCursor() - if key_sequence.matches(QtGui.QKeySequence(QtCore.Qt.Key.Key_Up)): + if _exact_match( + key_sequence, + QtGui.QKeySequence(QtCore.Qt.Key.Key_Up), + ): cursor.movePosition(QtGui.QTextCursor.MoveOperation.StartOfLine) if cursor.atStart(): self.setTextCursor(cursor) event.accept() - elif key_sequence.matches(QtGui.QKeySequence(QtCore.Qt.Key.Key_Down)): + elif _exact_match( + key_sequence, + QtGui.QKeySequence(QtCore.Qt.Key.Key_Down), + ): cursor.movePosition(QtGui.QTextCursor.MoveOperation.EndOfLine) if cursor.atEnd(): self.setTextCursor(cursor) event.accept() - elif self.auto_indent and key_sequence.matches( + elif self.auto_indent and _exact_match( + key_sequence, QtGui.QKeySequence(QtCore.Qt.Key.Key_Return) ): event.accept() return self.autoindent_newline() - elif key_sequence.matches(self.indent_key): + elif _exact_match(key_sequence, self.indent_key): event.accept() return self.block_indent() - elif key_sequence.matches(self.unindent_key): + elif _exact_match(key_sequence, self.unindent_key): event.accept() return self.block_unindent() - elif key_sequence.matches(self.comment_key): + elif _exact_match(key_sequence, self.comment_key): event.accept() return self.block_comment() elif ( self.auto_indent and self.smart_backspace - and key_sequence.matches(self.backspace_key) + and _exact_match(key_sequence, self.backspace_key) and self._backspace_should_unindent() ): event.accept() @@ -595,6 +608,13 @@ def __init__(self, parent, font=None, lexer=None): self.setLayout(layout) + # key bindings + self.find_key = QtGui.QKeySequence(QtGui.QKeySequence.StandardKey.Find) + self.replace_key = QtGui.QKeySequence(QtGui.QKeySequence.StandardKey.Replace) # noqa: E501 + # not all platforms have a standard replace key; use Ctrl+Alt+F + if self.replace_key.isEmpty(): + self.replace_key = QtGui.QKeySequence("Ctrl+Alt+F") + def _remove_event_listeners(self): self.code.selectionChanged.disconnect(self._update_replace_enabled) @@ -789,13 +809,17 @@ def centerCursor(self): # ------------------------------------------------------------------------ def keyPressEvent(self, event): - key_sequence = QtGui.QKeySequence(event.key() + int(event.modifiers())) - if key_sequence.matches(QtGui.QKeySequence.StandardKey.Find): + if is_qt5: + key_sequence = QtGui.QKeySequence(event.key() + int(event.modifiers())) + else: + key_sequence = QtGui.QKeySequence(event.keyCombination()) + + if _exact_match(key_sequence, self.find_key): self.enable_find() - elif key_sequence.matches(QtGui.QKeySequence.StandardKey.Replace): + elif _exact_match(key_sequence, self.replace_key): if not self.code.isReadOnly(): self.enable_replace() - elif key_sequence.matches(QtGui.QKeySequence(QtCore.Qt.Key.Key_Escape)): + elif _exact_match(key_sequence, QtGui.QKeySequence(QtCore.Qt.Key.Key_Escape)): if self.active_find_widget: self.find.hide() self.replace.hide() diff --git a/pyface/ui/qt4/code_editor/tests/test_code_widget.py b/pyface/ui/qt4/code_editor/tests/test_code_widget.py index c926a0931..b12e348b4 100644 --- a/pyface/ui/qt4/code_editor/tests/test_code_widget.py +++ b/pyface/ui/qt4/code_editor/tests/test_code_widget.py @@ -10,10 +10,9 @@ import unittest -from unittest import mock -from pyface.qt import QtCore, QtGui +from pyface.qt import QtGui from pyface.qt.QtTest import QTest @@ -62,49 +61,28 @@ def test_readonly_replace_widget(self): acw.code.setPlainText(text) acw.show() - # On some platforms, Find/Replace do not have default keybindings - FindKey = QtGui.QKeySequence("Ctrl+F") - ReplaceKey = QtGui.QKeySequence("Ctrl+H") - patcher_find = mock.patch("pyface.qt.QtGui.QKeySequence.StandardKey.Find", FindKey) - patcher_replace = mock.patch( - "pyface.qt.QtGui.QKeySequence.StandardKey.Replace", ReplaceKey - ) - patcher_find.start() - patcher_replace.start() - self.addCleanup(patcher_find.stop) - self.addCleanup(patcher_replace.stop) - def click_key_seq(widget, key_seq): if not isinstance(key_seq, QtGui.QKeySequence): key_seq = QtGui.QKeySequence(key_seq) - try: - # QKeySequence on python3-pyside does not have `len` - first_key = key_seq[0] - except IndexError: - return False - key = QtCore.Qt.Key(first_key & ~QtCore.Qt.KeyboardModifier.KeyboardModifierMask) - modifier = QtCore.Qt.KeyboardModifier( - first_key & QtCore.Qt.KeyboardModifier.KeyboardModifierMask - ) - QTest.keyClick(widget, key, modifier) + QTest.keySequence(widget, key_seq) return True acw.code.setReadOnly(True) - if click_key_seq(acw, FindKey): + if click_key_seq(acw, acw.find_key): self.assertTrue(acw.find.isVisible()) acw.find.hide() acw.code.setReadOnly(False) - if click_key_seq(acw, FindKey): + if click_key_seq(acw, acw.find_key): self.assertTrue(acw.find.isVisible()) acw.find.hide() acw.code.setReadOnly(True) - if click_key_seq(acw, ReplaceKey): + if click_key_seq(acw, acw.replace_key): self.assertFalse(acw.replace.isVisible()) acw.code.setReadOnly(False) - if click_key_seq(acw, ReplaceKey): + if click_key_seq(acw, acw.replace_key): self.assertTrue(acw.replace.isVisible()) acw.replace.hide() self.assertFalse(acw.replace.isVisible()) diff --git a/pyface/ui/qt4/data_view/data_view_item_model.py b/pyface/ui/qt4/data_view/data_view_item_model.py index 716dddcc6..b861c7f28 100644 --- a/pyface/ui/qt4/data_view/data_view_item_model.py +++ b/pyface/ui/qt4/data_view/data_view_item_model.py @@ -239,7 +239,7 @@ def setData(self, index, value, role=Qt.ItemDataRole.EditRole): value_type.set_text(self.model, row, column, value) elif role == Qt.ItemDataRole.CheckStateRole: if value_type.has_check_state(self.model, row, column): - state = set_check_state_map[value] + state = set_check_state_map[Qt.CheckState(value)] value_type.set_check_state(self.model, row, column, state) except DataViewSetError: diff --git a/pyface/ui/qt4/dialog.py b/pyface/ui/qt4/dialog.py index e01302180..bbd7dddc5 100644 --- a/pyface/ui/qt4/dialog.py +++ b/pyface/ui/qt4/dialog.py @@ -26,12 +26,12 @@ # Map PyQt dialog related constants to the pyface equivalents. _RESULT_MAP = { - int(QtGui.QDialog.DialogCode.Accepted): OK, - int(QtGui.QDialog.DialogCode.Rejected): CANCEL, - int(QtGui.QMessageBox.StandardButton.Ok): OK, - int(QtGui.QMessageBox.StandardButton.Cancel): CANCEL, - int(QtGui.QMessageBox.StandardButton.Yes): YES, - int(QtGui.QMessageBox.StandardButton.No): NO, + QtGui.QDialog.DialogCode.Accepted: OK, + QtGui.QDialog.DialogCode.Rejected: CANCEL, + QtGui.QMessageBox.StandardButton.Ok: OK, + QtGui.QMessageBox.StandardButton.Cancel: CANCEL, + QtGui.QMessageBox.StandardButton.Yes: YES, + QtGui.QMessageBox.StandardButton.No: NO, } diff --git a/pyface/ui/qt4/tasks/advanced_editor_area_pane.py b/pyface/ui/qt4/tasks/advanced_editor_area_pane.py index f33b3b67c..57274d696 100644 --- a/pyface/ui/qt4/tasks/advanced_editor_area_pane.py +++ b/pyface/ui/qt4/tasks/advanced_editor_area_pane.py @@ -632,7 +632,7 @@ def __init__(self, editor, parent=None): style = self.style() contents_minsize.setHeight( contents_minsize.height() - + style.pixelMetric(style.PM_DockWidgetHandleExtent) + + style.pixelMetric(style.PixelMetric.PM_DockWidgetHandleExtent) ) self.setMinimumSize(contents_minsize) diff --git a/pyface/ui/qt4/tasks/dock_pane.py b/pyface/ui/qt4/tasks/dock_pane.py index 81fd1eac3..7d58a8412 100644 --- a/pyface/ui/qt4/tasks/dock_pane.py +++ b/pyface/ui/qt4/tasks/dock_pane.py @@ -28,7 +28,7 @@ "top": QtCore.Qt.DockWidgetArea.TopDockWidgetArea, "bottom": QtCore.Qt.DockWidgetArea.BottomDockWidgetArea, } -INVERSE_AREA_MAP = dict((int(v), k) for k, v in AREA_MAP.items()) +INVERSE_AREA_MAP = {v: k for k, v in AREA_MAP.items()} @provides(IDockPane) @@ -85,7 +85,7 @@ def create(self, parent): style = control.style() contents_minsize.setHeight( contents_minsize.height() - + style.pixelMetric(style.PM_DockWidgetHandleExtent) + + style.pixelMetric(style.PixelMetric.PM_DockWidgetHandleExtent) ) control.setMinimumSize(contents_minsize) @@ -185,8 +185,8 @@ def _set_visible(self, event): def _receive_dock_area(self, area): with self._signal_context(): - if int(area) in INVERSE_AREA_MAP: - self.dock_area = INVERSE_AREA_MAP[int(area)] + if area in INVERSE_AREA_MAP: + self.dock_area = INVERSE_AREA_MAP[area] def _receive_floating(self, floating): with self._signal_context(): diff --git a/pyface/ui/qt4/tasks/task_window_backend.py b/pyface/ui/qt4/tasks/task_window_backend.py index f4422b748..60c486c4c 100644 --- a/pyface/ui/qt4/tasks/task_window_backend.py +++ b/pyface/ui/qt4/tasks/task_window_backend.py @@ -94,7 +94,7 @@ def get_layout(self): # Extract the window's corner configuration. for name, corner in CORNER_MAP.items(): - area = INVERSE_AREA_MAP[int(self.control.corner(corner))] + area = INVERSE_AREA_MAP[self.control.corner(corner)] setattr(layout, name + "_corner", area) return layout diff --git a/pyproject.toml b/pyproject.toml index 652320d56..3dfd11a5b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,7 +33,7 @@ pillow = ['pillow'] pyqt5 = ['pyqt5', 'pygments'] pyqt6 = ['pyqt6', 'pygments'] pyside2 = ['pyside2', 'pygments'] -pyside6 = ['pyside6<6.4', 'pygments'] +pyside6 = ['pyside6', 'pygments'] numpy = ['numpy'] traitsui = ['traitsui'] test = ['packaging']