diff --git a/PyInstaller/fake-modules/_pyi_rth_utils/qt.py b/PyInstaller/fake-modules/_pyi_rth_utils/qt.py new file mode 100644 index 0000000000..dff62c81f2 --- /dev/null +++ b/PyInstaller/fake-modules/_pyi_rth_utils/qt.py @@ -0,0 +1,97 @@ +# ----------------------------------------------------------------------------- +# Copyright (c) 2024, PyInstaller Development Team. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# +# The full license is in the file COPYING.txt, distributed with this software. +# +# SPDX-License-Identifier: Apache-2.0 +# ----------------------------------------------------------------------------- + +import os +import importlib +import atexit + +# Helper for relocating Qt prefix via embedded qt.conf file. +_QT_CONF_FILENAME = ":/qt/etc/qt.conf" + +_QT_CONF_RESOURCE_NAME = ( + # qt + b"\x00\x02" + b"\x00\x00\x07\x84" + b"\x00\x71" + b"\x00\x74" + # etc + b"\x00\x03" + b"\x00\x00\x6c\xa3" + b"\x00\x65" + b"\x00\x74\x00\x63" + # qt.conf + b"\x00\x07" + b"\x08\x74\xa6\xa6" + b"\x00\x71" + b"\x00\x74\x00\x2e\x00\x63\x00\x6f\x00\x6e\x00\x66" +) + +_QT_CONF_RESOURCE_STRUCT = ( + # : + b"\x00\x00\x00\x00\x00\x02\x00\x00\x00\x01\x00\x00\x00\x01" + # :/qt + b"\x00\x00\x00\x00\x00\x02\x00\x00\x00\x01\x00\x00\x00\x02" + # :/qt/etc + b"\x00\x00\x00\x0a\x00\x02\x00\x00\x00\x01\x00\x00\x00\x03" + # :/qt/etc/qt.conf + b"\x00\x00\x00\x16\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00" +) + + +def create_embedded_qt_conf(qt_bindings, prefix_path): + QtCore = importlib.import_module(qt_bindings + ".QtCore") + + # No-op if embedded qt.conf already exists + if QtCore.QFile.exists(_QT_CONF_FILENAME): + return + + # Create qt.conf file that relocates Qt prefix. + # NOTE: paths should use POSIX-style forward slashes as separator, even on Windows. + if os.sep == '\\': + prefix_path = prefix_path.replace(os.sep, '/') + + qt_conf = f"[Paths]\nPrefix = {prefix_path}\n" + if os.name == 'nt' and qt_bindings in {"PySide2", "PySide6"}: + # PySide PyPI wheels on Windows set LibraryExecutablesPath to PrefixPath + qt_conf += f"LibraryExecutables = {prefix_path}" + + # Encode the contents; in Qt5, QSettings uses Latin1 encoding, in Qt6, it uses UTF8. + if qt_bindings in {"PySide2", "PyQt5"}: + qt_conf = qt_conf.encode("latin1") + else: + qt_conf = qt_conf.encode("utf-8") + + # Prepend data size (32-bit integer, big endian) + qt_conf_size = len(qt_conf) + qt_resource_data = qt_conf_size.to_bytes(4, 'big') + qt_conf + + # Register + succeeded = QtCore.qRegisterResourceData( + 0x01, + _QT_CONF_RESOURCE_STRUCT, + _QT_CONF_RESOURCE_NAME, + qt_resource_data, + ) + if not succeeded: + return # Tough luck + + # Unregister the resource at exit, to ensure that the registered resource on Qt/C++ side does not outlive the + # `_qt_resource_data` python variable and its data buffer. This also adds a reference to the `_qt_resource_data`, + # which conveniently ensures that the data is not garbage collected before we perform the cleanup (otherwise garbage + # collector might kick in at any time after we exit this helper function, and `qRegisterResourceData` does not seem + # to make a copy of the data!). + atexit.register( + QtCore.qUnregisterResourceData, + 0x01, + _QT_CONF_RESOURCE_STRUCT, + _QT_CONF_RESOURCE_NAME, + qt_resource_data, + ) diff --git a/PyInstaller/hooks/rthooks/pyi_rth_pyqt5.py b/PyInstaller/hooks/rthooks/pyi_rth_pyqt5.py index b8299d3b99..032e74590b 100644 --- a/PyInstaller/hooks/rthooks/pyi_rth_pyqt5.py +++ b/PyInstaller/hooks/rthooks/pyi_rth_pyqt5.py @@ -18,6 +18,7 @@ def _pyi_rthook(): import sys from _pyi_rth_utils import is_macos_app_bundle, prepend_path_to_environment_variable + from _pyi_rth_utils import qt as qt_rth_utils # Try PyQt5 5.15.4-style path first... pyqt_path = os.path.join(sys._MEIPASS, 'PyQt5', 'Qt5') @@ -53,6 +54,12 @@ def _pyi_rthook(): if sys.platform.startswith('win'): prepend_path_to_environment_variable(sys._MEIPASS, 'PATH') + # Qt bindings package installed via PyPI wheels typically ensures that its bundled Qt is relocatable, by creating + # embedded `qt.conf` file during its initialization. This run-time generated qt.conf dynamically sets the Qt prefix + # path to the package's Qt directory. For bindings packages that do not create embedded `qt.conf` during their + # initialization (for example, conda-installed packages), try to perform this step ourselves. + qt_rth_utils.create_embedded_qt_conf("PyQt5", pyqt_path) + _pyi_rthook() del _pyi_rthook diff --git a/PyInstaller/hooks/rthooks/pyi_rth_pyqt6.py b/PyInstaller/hooks/rthooks/pyi_rth_pyqt6.py index df62c37638..a3b6e52141 100644 --- a/PyInstaller/hooks/rthooks/pyi_rth_pyqt6.py +++ b/PyInstaller/hooks/rthooks/pyi_rth_pyqt6.py @@ -18,6 +18,7 @@ def _pyi_rthook(): import sys from _pyi_rth_utils import is_macos_app_bundle, prepend_path_to_environment_variable + from _pyi_rth_utils import qt as qt_rth_utils # Try PyQt6 6.0.3-style path first... pyqt_path = os.path.join(sys._MEIPASS, 'PyQt6', 'Qt6') @@ -55,6 +56,12 @@ def _pyi_rthook(): if sys.platform == 'darwin' and not is_macos_app_bundle: prepend_path_to_environment_variable(sys._MEIPASS, 'DYLD_LIBRARY_PATH') + # Qt bindings package installed via PyPI wheels typically ensures that its bundled Qt is relocatable, by creating + # embedded `qt.conf` file during its initialization. This run-time generated qt.conf dynamically sets the Qt prefix + # path to the package's Qt directory. For bindings packages that do not create embedded `qt.conf` during their + # initialization (for example, conda-installed packages), try to perform this step ourselves. + qt_rth_utils.create_embedded_qt_conf("PyQt6", pyqt_path) + _pyi_rthook() del _pyi_rthook diff --git a/PyInstaller/hooks/rthooks/pyi_rth_pyside2.py b/PyInstaller/hooks/rthooks/pyi_rth_pyside2.py index 3d7c684f7a..66a7c4f432 100644 --- a/PyInstaller/hooks/rthooks/pyi_rth_pyside2.py +++ b/PyInstaller/hooks/rthooks/pyi_rth_pyside2.py @@ -18,6 +18,7 @@ def _pyi_rthook(): import sys from _pyi_rth_utils import is_macos_app_bundle, prepend_path_to_environment_variable + from _pyi_rth_utils import qt as qt_rth_utils if sys.platform.startswith('win'): pyqt_path = os.path.join(sys._MEIPASS, 'PySide2') @@ -48,6 +49,12 @@ def _pyi_rthook(): if sys.platform.startswith('win'): prepend_path_to_environment_variable(sys._MEIPASS, 'PATH') + # Qt bindings package installed via PyPI wheels typically ensures that its bundled Qt is relocatable, by creating + # embedded `qt.conf` file during its initialization. This run-time generated qt.conf dynamically sets the Qt prefix + # path to the package's Qt directory. For bindings packages that do not create embedded `qt.conf` during their + # initialization (for example, conda-installed packages), try to perform this step ourselves. + qt_rth_utils.create_embedded_qt_conf("PySide2", pyqt_path) + _pyi_rthook() del _pyi_rthook diff --git a/PyInstaller/hooks/rthooks/pyi_rth_pyside6.py b/PyInstaller/hooks/rthooks/pyi_rth_pyside6.py index 6139725f78..b7bc7f6b6a 100644 --- a/PyInstaller/hooks/rthooks/pyi_rth_pyside6.py +++ b/PyInstaller/hooks/rthooks/pyi_rth_pyside6.py @@ -18,6 +18,7 @@ def _pyi_rthook(): import sys from _pyi_rth_utils import is_macos_app_bundle, prepend_path_to_environment_variable + from _pyi_rth_utils import qt as qt_rth_utils if sys.platform.startswith('win'): pyqt_path = os.path.join(sys._MEIPASS, 'PySide6') @@ -54,6 +55,12 @@ def _pyi_rthook(): if sys.platform == 'darwin' and not is_macos_app_bundle: prepend_path_to_environment_variable(sys._MEIPASS, 'DYLD_LIBRARY_PATH') + # Qt bindings package installed via PyPI wheels typically ensures that its bundled Qt is relocatable, by creating + # embedded `qt.conf` file during its initialization. This run-time generated qt.conf dynamically sets the Qt prefix + # path to the package's Qt directory. For bindings packages that do not create embedded `qt.conf` during their + # initialization (for example, conda-installed packages), try to perform this step ourselves. + qt_rth_utils.create_embedded_qt_conf("PySide6", pyqt_path) + _pyi_rthook() del _pyi_rthook diff --git a/news/8314.hooks.rst b/news/8314.hooks.rst new file mode 100644 index 0000000000..d07fac490a --- /dev/null +++ b/news/8314.hooks.rst @@ -0,0 +1,8 @@ +Have run-time hooks for Qt bindings (``PySide2``, ``PySide6``, ``PyQt5``, +and ``PyQt6``) check for presence of the embedded ``:/qt/etc/qt.conf`` +resource, and if not present, inject their own version. This +aims to ensure that the bundled Qt is always relocatable, even if the +package does not perform injection of embedded ``qt.conf`` file (most +notably, this seems to be the case with ``PySide2`` collected from +Linux distribution packages, and ``PySide2`` collected from Anaconda +on Windows, Linux, and macOS).