Skip to content

Add PyQt6 support #431

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 7 commits into
base: master
Choose a base branch
from
Draft

Conversation

MHendricks
Copy link
Collaborator

Add the binding information for PyQt6 support.

PyQt6 seems to have removed a lot of backwards compatible support that PySide6 has not. This introduces a few issues that should be figured out before this is merged. I will add additional notes calling out the specifics.

@MHendricks
Copy link
Collaborator Author

I wanted to ask how to solve the two failing PyQt6 tests. It looks like PyQt6 is capturing the FileNotFoundError error that is getting raised and these tests are looking for and wrapping it in a generic PyQt6.uic.exceptions.UIFileException that only inherits from Exception not from FileNotFoundError. This breaks the test, but also makes it so if a Qt.py user wanted to handle missing file exceptions they now have to account for if you are using PyQt6.

Do we accept this limitation and I just update the tests so they pass, or should I look into extracting the original exception and raise it in the _loadUi function?

The exception raised by the test:

ERROR: tests.test_load_ui_invalidpath
Tests to see if loadUi successfully fails on invalid paths
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/home/runner/.local/lib/python3.10/site-packages/PyQt6/uic/ui_file.py", line 35, in __init__
    document = ElementTree.parse(ui_file)
  File "/usr/lib/python3.10/xml/etree/ElementTree.py", line 1222, in parse
    tree.parse(source, parser)
  File "/usr/lib/python3.10/xml/etree/ElementTree.py", line 569, in parse
    source = open(source, "rb")
FileNotFoundError: [Errno 2] No such file or directory: 'made/up/path'

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "/home/runner/work/Qt.py/Qt.py/tests.py", line 622, in test_load_ui_invalidpath
    assert_raises(IOError, QtCompat.loadUi, 'made/up/path')
  File "/usr/lib/python3.10/unittest/case.py", line 738, in assertRaises
    return context.handle('assertRaises', args, kwargs)
  File "/usr/lib/python3.10/unittest/case.py", line 201, in handle
    callable_obj(*args, **kwargs)
  File "/home/runner/work/Qt.py/Qt.py/Qt.py", line 798, in _loadUi
    return Qt._uic.loadUi(uifile, baseinstance)
  File "/home/runner/.local/lib/python3.10/site-packages/PyQt6/uic/load_ui.py", line 86, in loadUi
    return DynamicUILoader(package).loadUi(uifile, baseinstance)
  File "/home/runner/.local/lib/python3.10/site-packages/PyQt6/uic/Loader/loader.py", line 62, in loadUi
    return self.parse(filename)
  File "/home/runner/.local/lib/python3.10/site-packages/PyQt6/uic/uiparser.py", line 995, in parse
    ui_file = UIFile(filename)
  File "/home/runner/.local/lib/python3.10/site-packages/PyQt6/uic/ui_file.py", line 37, in __init__
    self._raise_exception("invalid Qt Designer file", detail=str(e))
  File "/home/runner/.local/lib/python3.10/site-packages/PyQt6/uic/ui_file.py", line 92, in _raise_exception
    raise UIFileException(self._ui_file, message, detail=detail)
PyQt6.uic.exceptions.UIFileException: made/up/path: invalid Qt Designer file: [Errno 2] No such file or directory: 'made/up/path'

CAVEATS.md Outdated
Comment on lines 391 to 411
#### Fully Qualified Enums

In Qt6 both PySide6 and PyQt6 are moving from custom Enum classes to python Enums.
This means you should be moving from short form Enums `QFont.Bold` to
fully qualified Enum names `QFont.Weight.Bold`.

PySide6 currently has a [forgiveness mode](https://doc.qt.io/qtforpython-6/considerations.html#doing-a-smooth-transition-from-the-old-enums) where you can still use `QFont.Bold`
on a Qt class object but no longer let you use `QFont().Bold` on a instance of the
class. PyQt6 doesn't let you use either of these options and you must use the fully
qualified Enum name `QFont.Weight.Bold`.

PySide, PyQt4 and older releases of PySide2 and PyQt5 can only use short enums
and are not compatible with fully qualified enum names. If you need to support Qt4
and Qt5 then use short enum's. Unfortunately your code won't easily work with Qt6.

For Qt5 and Qt6 support, for maximum compatibility moving forward, you should
always use the fully qualified enum name. Even if you only plan to support
PySide5/6 you are encouraged to use the fully qualified names for future proofing.

This https://stackoverflow.com/a/72658216 post contains a script that makes it easy
to convert code to fully qualified enums.
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see this as the biggest issue with PyQt6 in Qt.py. From experience as one of the few people using PyQt bindings most devs only test their code in PySide and won't run into the missing short enum's.

Once I got into running the tests, I discovered that Qt4 and the older versions of Qt5 in the VFXPLATFORM docker images don't support the new enum names. So this can't just be solved by dropping Qt4 support, we would have to consider dropping support for older versions of Qt5.

I considered building some sort of compatibility mapping of all enums, but it's not just QtCore.Qt that has this enum issue. A lot of classes have their own Enums like QFont.Weight.Bold. Alternatively, adding a enum lookup method like get_enum in test.py could work but seems unlikely to be used by users unless somehow forced.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just read up on #413 (comment) and looks like we are planning to drop Qt4 support. If we also drop support for older versions of Qt5 then we can just encourage using the fully qualified enum names.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've been using the https://stackoverflow.com/a/72658216 and ran into problems with it not supporting sub-classes. For example it didn't find QTreeWidget.ScrollPerPixel because that is defined on its superclass QAbstractItemView.ScrollMode.ScrollPerPixel.

We have a lot of code we will need to convert so I'm working on improving it using ast to parse the .pyi file and adding support for using PySide6 which the original would not have supported.

I found out that Qt.py has a cli that I never noticed so I'm thinking of adding it there. Though this may be its own merge request.

@@ -603,17 +603,21 @@ python -m twine upload .\dist\*

| Replace | With | Notes
|:--------|:-----|:----------------------------
| `QFont().fromString(...)` | `QtCompat.QFont.fromString(font, ...)` | Qt6 adds extra data to font strings and changes weight if you want to pass a font string from Qt6 to Qt4/5 use this
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes it possible to take a font string saved in Qt6 easily be loaded in Qt5. By default it would log a warning and not load the font.

>>> fs = "Arial,7,-1,5,400,0,0,0,0,0,0,0,0,0,0,1"
>>> from PySide2.QtGui import QFont
>>> f = QFont()
>>> f.fromString(fs)
QFont::fromString: Invalid description 'Arial,7,-1,5,400,0,0,0,0,0,0,0,0,0,0,1'
False
>>> f
<PySide2.QtGui.QFont(,-1,-1,5,50,0,0,0,0,0) at 0x000001196C664480>
>>> from PyQt5.QtGui import QFont
>>> f = QFont()
>>> f.fromString(fs)
QFont::fromString: Invalid description 'Arial,7,-1,5,400,0,0,0,0,0,0,0,0,0,0,1'
False
>>> f
<PyQt5.QtGui.QFont object at 0x00000119698EFD80>

| `QtCore.Qt.MidButton` | `QtCompat.Qt.MidButton`
| `QLabel.setPixmap(str)` | `QLabel.setPixmap(QPixmap())` | Can't take a string anymore (tested in Maya 2025.0)
| `QModelIndex.child` | `QModel.index` | This one is apparently from Qt 4 and should not have been in Qt.py to begin with
| `QApplication.exec_()` | `QtCompat.QApplication.exec_()` | `exec` is no longer a reserved keyword in python 3 so Qt6 is removing the underscore from `exec_`. Qt.py is using exec_ to preserve compatibility with python 2. The same applies for `QCoreApplication`.
| `QAction().setShortcut(Qt.SHIFT\|Qt.Key_Backspace)` | `QAction().setShortcut(QKeySequence(Qt.Modifier.SHIFT\|Qt.Key.Key_Backspace))` | PyQt6 doesn't accept `QKeyCombination` objects for shortcuts. To work around this cast them to `QKeySequence` objects.
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Another PyQt6 only issue. When building keyboard shortcuts, the PyQt6 bindings don't accept the new QKeyCombination objects so you have to cast them to QKeySequence before passing them.

PySide6:

>>> from PySide6 import QtCore, QtGui
>>> act = QtGui.QAction()
>>> act.setShortcut(QtCore.Qt.Modifier.SHIFT | QtCore.Qt.Key.Key_Backspace)

PyQt6:

>>> from PySide6 import QtCore, QtGui
>>> QtGui.QAction().setShortcut(QtCore.Qt.Modifier.SHIFT | QtCore.Qt.Key.Key_Backspace)
>>> from PyQt6 import QtCore, QtGui
>>> QtGui.QAction().setShortcut(QtCore.Qt.Modifier.SHIFT | QtCore.Qt.Key.Key_Backspace)

Traceback (most recent call last):
  File "C:\blur\dev\preditor\preditor\gui\console.py", line 586, in keyPressEvent
    self.executeCommand()
  File "C:\blur\dev\preditor\preditor\gui\console.py", line 469, in executeCommand
    cmdresult, wasEval = self.executeString(commandText)
                         ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "C:\blur\dev\preditor\preditor\gui\console.py", line 422, in executeString
    cmdresult = eval(compiled, __main__.__dict__, __main__.__dict__)
                ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "<ConsolePrEdit>", line 1, in <module>
TypeError: setShortcut(self, shortcut: Union[QKeySequence, QKeySequence.StandardKey, Optional[str], int]): argument 1 has unexpected type 'QKeyCombination'

PyQt6 fix:

>>> QtGui.QAction().setShortcut(QtGui.QKeySequence(QtCore.Qt.Modifier.SHIFT | QtCore.Qt.Key.Key_Backspace))

| `QtCore.Qt.MidButton` | `QtCompat.Qt.MidButton`
| `QLabel.setPixmap(str)` | `QLabel.setPixmap(QPixmap())` | Can't take a string anymore (tested in Maya 2025.0)
| `QModelIndex.child` | `QModel.index` | This one is apparently from Qt 4 and should not have been in Qt.py to begin with
| `QApplication.exec_()` | `QtCompat.QApplication.exec_()` | `exec` is no longer a reserved keyword in python 3 so Qt6 is removing the underscore from `exec_`. Qt.py is using exec_ to preserve compatibility with python 2. The same applies for `QCoreApplication`.
| `QAction().setShortcut(Qt.SHIFT\|Qt.Key_Backspace)` | `QAction().setShortcut(QKeySequence(Qt.Modifier.SHIFT\|Qt.Key.Key_Backspace))` | PyQt6 doesn't accept `QKeyCombination` objects for shortcuts. To work around this cast them to `QKeySequence` objects.
| int(QMainWindow().windowState()) | QtCompat.enumValue(QMainWindow().windowState()) | Consistent interface to convert an enum to an `int`
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added a compatibility function to convert enums to int values.

For user prefs I often store the int value of flags and enums in prefs files and restore them. The new enum implementation for both PySide6 and PyQt6 require converting to int differently. For PyQt4/5 you can just cast them to ints(for some bindings they are already ints). In Qt6 you have to call enum.value to get the enum value.

>>> from PySide6 import QtCore 
>>> int(QtCore.Qt.WindowState.WindowActive)

Traceback (most recent call last):
  File "C:\blur\dev\preditor\preditor\gui\console.py", line 586, in keyPressEvent
    self.executeCommand()
  File "C:\blur\dev\preditor\preditor\gui\console.py", line 469, in executeCommand
    cmdresult, wasEval = self.executeString(commandText)
                         ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "C:\blur\dev\preditor\preditor\gui\console.py", line 422, in executeString
    cmdresult = eval(compiled, __main__.__dict__, __main__.__dict__)
                ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "<ConsolePrEdit>", line 1, in <module>
TypeError: int() argument must be a string, a bytes-like object or a real number, not 'WindowState'
>>> QtCore.Qt.WindowState.WindowActive.value
8

@mottosso
Copy link
Owner

mottosso commented Apr 2, 2025

Great initiative. I'm in the start of a house-move at the moment and will be slow to reply, but I would like invite others to join in on the conversation too so if you are reading this, do chime in.

- Don't mutate the current test suite binding when testing None. This was causing
the tests after it runs to use the wrong binding.
- Restore the original working directory to prevent file lock issues when testing on windows.
- run_tests.py now includes the failing bindings in the summary exception for ease of debugging.
- QtCompat.QFont.fromString adds support for Qt6 font strings in Qt4/5.
- QtCompat.enumValue interface for converting Qt enums to int values.
This removes the UIFileException exception if the cause exception is not
PyQt6 specific. This should make it easier to handle exceptions consistently
between bindings.

Updated readme documentation per mottosso#429
@MHendricks
Copy link
Collaborator Author

I wanted to ask how to solve the two failing PyQt6 tests. It looks like PyQt6 is capturing the FileNotFoundError error that is getting raised and these tests are looking for and wrapping it in a generic PyQt6.uic.exceptions.UIFileException that only inherits from Exception not from FileNotFoundError. This breaks the test, but also makes it so if a Qt.py user wanted to handle missing file exceptions they now have to account for if you are using PyQt6.

Do we accept this limitation and I just update the tests so they pass, or should I look into extracting the original exception and raise it in the _loadUi function?

I chose the latter. For known exceptions we are testing for IOError and xml.etree.ElementTree.ParseError it will raise the generic exception(error.__context__). Otherwise it will raise the original PyQt6 specific errors, but for now I'm guessing it's unlikely that devs will be handling those errors in code.

Tests for Qt_convert_enum.py are written but require at least python 3.7 to run successfully.
These tests will be enabled once the underlying testing suite is updated to current requirements.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants