Skip to content

Commit

Permalink
rhtooks: qt: create embedded qt.conf if it does not exist
Browse files Browse the repository at this point in the history
Have the run-time hooks for `PySide2`, `PySide6`, `PyQt5`, and
`PyQt6` check for the presence of 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.

All Qt bindings packages, when packaged as PyPI wheels, inject
embedded `qt.conf` file as Qt embedded resource during their
initialization, and use it to set the `PrefixPath` to the
top-level Qt directory within the package's directory. This
enures that all paths in `QLibraryInfo` are properly set, and
is essential for making the package and its bundled Qt
relocatable.

Qt bindings packaged by other providers (for example, Anaconda
packages on all OSes, or packages provided by linux
distributions) do not seem to use this mechanism when running
under unfrozen python.

However, when collected into PyInstaller-frozen application,
`PyQt5` and `PyQt6` packages, even if originally not packaged
as PyPI wheels, seem to enable the embedded `qt.conf` injection,
making themselves relocatable.

On the other hand, `PySide2` package does not, which makes it
non-relocatable. For example, when using Fedora-packaged
`PySide2` in a frozen application, the `PrefixPath` remains
set to `/usr`, and thus a `QtWebEngine`-enabled frozen application
ends up using system-installed `QtWebEngineProcess` helper in
`/usr/lib64/qt5/libexec`, if available (and failing to start if it
is unavailable). In the case of Anaconda-packaged `PySide2`, the
`PrefixPath` path ends up being `/`, and the collected
`QtWebEngineProcess` fails to be discovered.

Therefore, if the embedded `qt.conf` resource is not provided by
the package itself, we attempt to provide our own, to ensure that
the package is relocatable, and that paths in `QLibraryInfo` are
properly set (i.e., that they match the relocatable PyPI wheel
layout, which we are trying to reconstruct in our Qt collection
approach).
  • Loading branch information
rokm committed Feb 20, 2024
1 parent 503dc55 commit 1b5c91c
Show file tree
Hide file tree
Showing 6 changed files with 133 additions and 0 deletions.
97 changes: 97 additions & 0 deletions 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,
)
7 changes: 7 additions & 0 deletions PyInstaller/hooks/rthooks/pyi_rth_pyqt5.py
Expand Up @@ -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')
Expand Down Expand Up @@ -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
7 changes: 7 additions & 0 deletions PyInstaller/hooks/rthooks/pyi_rth_pyqt6.py
Expand Up @@ -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')
Expand Down Expand Up @@ -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
7 changes: 7 additions & 0 deletions PyInstaller/hooks/rthooks/pyi_rth_pyside2.py
Expand Up @@ -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')
Expand Down Expand Up @@ -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
7 changes: 7 additions & 0 deletions PyInstaller/hooks/rthooks/pyi_rth_pyside6.py
Expand Up @@ -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')
Expand Down Expand Up @@ -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
8 changes: 8 additions & 0 deletions 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).

0 comments on commit 1b5c91c

Please sign in to comment.