Repository: spyder-ide/qtpy (language: python)

Issue:
Differences in QEvent subclass APIs in PyQt6 cause attribute and/or type errors
For example, my application uses qtconsole, which in turns uses QtPy. At one point, qtconsole does:
```
        elif etype == QtCore.QEvent.MouseMove:
            anchor = self._control.anchorAt(event.pos())
            QtWidgets.QToolTip.showText(event.globalPos(), anchor)
```
However, when running PyQt6, the method `QMouseEvent.globalPos()` has been removed (as it has also been removed in Qt6 itself). Simply changing to call `QMouseEvent.globalPosition()` doesn't work, as the return type of that is different, `QPoint` (old) v.s. `QPointF` (new).

This is particularly annoying as most event classes used to have the same API - `pos` and `globalPos` returning `QPoint`, sometimes `posF` etc. for a floating version. Now in Python you have to do a load of type checks when you have a method that handles multiple event types. In C++ this would be spotted at compile time, which is probably why the Qt devs thought it was a reasonable change to make.

I would propose mapping this method similar to how e.g. `QFontMetrics.width` is, by monkey patching it directly onto the class object after import. i.e.:

`QMouseEvent.globalPos = lambda self: self.globalPosition().toPoint()`

There may be other missing methods, for example `QMouseEvent.pos()` doesn't actually exist in Qt6 but is defined in PyQt6 anyway - I don't know whether it exists in PySide6.


Discussion / hints:
Hi @ArthurGW thank you for the feedback! That sounds like a good idea :+1: If you want to help us implementing the mappings let us know!

Fix patch:
diff --git a/qtpy/QtGui.py b/qtpy/QtGui.py
index 35a83eab..d51540df 100644
--- a/qtpy/QtGui.py
+++ b/qtpy/QtGui.py
@@ -87,12 +87,37 @@ def movePositionPatched(
 # Fix https://github.com/spyder-ide/qtpy/issues/394
 if PYQT5 or PYSIDE2:
     from qtpy.QtCore import QPointF as __QPointF
-    QMouseEvent.position = lambda self: __QPointF(float(self.x()), float(self.y()))
-    QMouseEvent.globalPosition = lambda self: __QPointF(float(self.globalX()), float(self.globalY()))
+    QNativeGestureEvent.x = lambda self: self.localPos().toPoint().x()
+    QNativeGestureEvent.y = lambda self: self.localPos().toPoint().y()
+    QNativeGestureEvent.position = lambda self: self.localPos()
+    QNativeGestureEvent.globalX = lambda self: self.globalPos().x()
+    QNativeGestureEvent.globalY = lambda self: self.globalPos().y()
+    QNativeGestureEvent.globalPosition = lambda self: __QPointF(
+        float(self.globalPos().x()), float(self.globalPos().y()))
+    QEnterEvent.position = lambda self: self.localPos()
+    QEnterEvent.globalPosition = lambda self: __QPointF(
+        float(self.globalX()), float(self.globalY()))
+    QTabletEvent.position = lambda self: self.posF()
+    QTabletEvent.globalPosition = lambda self: self.globalPosF()
+    QHoverEvent.x = lambda self: self.pos().x()
+    QHoverEvent.y = lambda self: self.pos().y()
+    QHoverEvent.position = lambda self: self.posF()
+    # No `QHoverEvent.globalPosition`, `QHoverEvent.globalX`,
+    # nor `QHoverEvent.globalY` in the Qt5 docs.
+    QMouseEvent.position = lambda self: self.localPos()
+    QMouseEvent.globalPosition = lambda self: __QPointF(
+        float(self.globalX()), float(self.globalY()))
 if PYQT6 or PYSIDE6:
-    QMouseEvent.pos = lambda self: self.position().toPoint()
-    QMouseEvent.x = lambda self: self.position().toPoint().x()
-    QMouseEvent.y = lambda self: self.position().toPoint().y()
-    QMouseEvent.globalPos = lambda self: self.globalPosition().toPoint()
-    QMouseEvent.globalX = lambda self: self.globalPosition().toPoint().x()
-    QMouseEvent.globalY = lambda self: self.globalPosition().toPoint().y()
+    for _class in (QNativeGestureEvent, QEnterEvent, QTabletEvent, QHoverEvent,
+                   QMouseEvent):
+        for _obsolete_function in ('pos', 'x', 'y', 'globalPos', 'globalX', 'globalY'):
+            if hasattr(_class, _obsolete_function):
+                delattr(_class, _obsolete_function)
+    QSinglePointEvent.pos = lambda self: self.position().toPoint()
+    QSinglePointEvent.posF = lambda self: self.position()
+    QSinglePointEvent.localPos = lambda self: self.position()
+    QSinglePointEvent.x = lambda self: self.position().toPoint().x()
+    QSinglePointEvent.y = lambda self: self.position().toPoint().y()
+    QSinglePointEvent.globalPos = lambda self: self.globalPosition().toPoint()
+    QSinglePointEvent.globalX = lambda self: self.globalPosition().toPoint().x()
+    QSinglePointEvent.globalY = lambda self: self.globalPosition().toPoint().y()


Test patch:
diff --git a/qtpy/tests/test_qtgui.py b/qtpy/tests/test_qtgui.py
index 787d7dba..36249415 100644
--- a/qtpy/tests/test_qtgui.py
+++ b/qtpy/tests/test_qtgui.py
@@ -77,7 +77,7 @@ def test_enum_access():
 @pytest.mark.skipif(
     sys.platform == 'darwin' and sys.version_info[:2] == (3, 7),
     reason="Stalls on macOS CI with Python 3.7")
-def test_QMouseEvent_pos_functions(qtbot):
+def test_QSomethingEvent_pos_functions(qtbot):
     """
     Test `QMouseEvent.pos` and related functions removed in Qt 6,
     and `QMouseEvent.position`, etc., missing from Qt 5.
@@ -101,8 +101,17 @@ def mouseDoubleClickEvent(self, event: QtGui.QMouseEvent) -> None:
     window.setMinimumSize(320, 240)  # ensure the window is of sufficient size
     window.show()
 
-    qtbot.mouseMove(window, QtCore.QPoint(42, 6 * 9))
-    qtbot.mouseDClick(window, QtCore.Qt.LeftButton)
+    with qtbot.waitExposed(window):
+        qtbot.mouseMove(window, QtCore.QPoint(42, 6 * 9))
+        qtbot.mouseDClick(window, QtCore.Qt.LeftButton)
+
+    # the rest of the functions are not actually tested
+    for _class in ('QNativeGestureEvent', 'QEnterEvent', 'QTabletEvent'):
+        for _function in ('pos', 'x', 'y', 'globalPos', 'globalX', 'globalY',
+                          'position', 'globalPosition'):
+            assert hasattr(getattr(QtGui, _class), _function)
+    for _function in ('pos', 'x', 'y', 'position'):
+        assert hasattr(QtGui.QHoverEvent, _function)
 
 
 @pytest.mark.skipif(not (PYSIDE2 or PYSIDE6), reason="PySide{2,6} specific test")