# ----------------------------------------------------------------------------
|
# Copyright (c) 2005-2023, PyInstaller Development Team.
|
#
|
# Distributed under the terms of the GNU General Public License (version 2
|
# or later) with exception for distributing the bootloader.
|
#
|
# The full license is in the file COPYING.txt, distributed with this software.
|
#
|
# SPDX-License-Identifier: (GPL-2.0-or-later WITH Bootloader-exception)
|
#-----------------------------------------------------------------------------
|
|
import glob
|
import os
|
import pathlib
|
|
from PyInstaller import compat
|
from PyInstaller import isolated
|
from PyInstaller import log as logging
|
from PyInstaller.depend import bindepend
|
from PyInstaller.utils import hooks, misc
|
from PyInstaller.utils.hooks.qt import _modules_info
|
|
logger = logging.getLogger(__name__)
|
|
# Qt deployment approach
|
# ----------------------
|
# This is the core of PyInstaller's approach to Qt deployment. It is based on:
|
#
|
# - Discovering the location of Qt libraries by introspection, using QtLibraryInfo_. This provides compatibility with
|
# many variants of Qt5/6 (conda, self-compiled, provided by a Linux distro, etc.) and many versions of Qt5/6, all of
|
# which vary in the location of Qt files.
|
#
|
# - Placing all frozen PyQt5/6 or PySide2/6 Qt files in a standard subdirectory layout, which matches the layout of the
|
# corresponding wheel on PyPI. This is necessary to support Qt installs which are not in a subdirectory of the PyQt5/6
|
# or PySide2/6 wrappers. See ``hooks/rthooks/pyi_rth_qt5.py`` for the use of environment variables to establish this
|
# layout.
|
#
|
# - Emitting warnings on missing QML and translation files which some installations do not have.
|
#
|
# - Determining additional files needed for deployment based on the information in the centralized Qt module information
|
# list in the ``_modules_info`` module. This includes plugins and translation files associated with each Qt python
|
# extension module, as well as additional python Qt extension modules.
|
#
|
# - Collecting additional files that are specific to each module and are handled separately, for example:
|
#
|
# - For dynamic OpenGL applications, the ``libEGL.dll``, ``libGLESv2.dll``, ``d3dcompiler_XX.dll`` (the XX is a
|
# version number), and ``opengl32sw.dll`` libraries need to be collected on Windows. This is handled by the
|
# "base" bindings hook, for example ``hook-PyQt5.py``.
|
#
|
# - If Qt was configured to use ICU, the ``icudtXX.dll``, ``icuinXX.dll``, and ``icuucXX.dll`` libraries need to
|
# be collected on Windows. This is handled by the "base" bindings hook, for example ``hook-PyQt5.py``.
|
#
|
# - Per the `Deploying QML Applications <http://doc.qt.io/qt-5/qtquick-deployment.html>`_ page, QML-based
|
# applications need the ``qml/`` directory available. This is handled by ``QtQuick`` hook, for example
|
# ``hook-PyQt5.QtQuick.py``.
|
#
|
# - For ``QtWebEngine``-based applications, we follow the `deployWebEngineCore
|
# <https://code.woboq.org/qt5/qttools/src/windeployqt/main.cpp.html#_ZL19deployWebEngineCoreRK4QMapI7QStringS0_ERK7OptionsbPS0_>`_
|
# function copies the following files from ``resources/``, and also copies the web engine process executable.
|
# - ``icudtl.dat``
|
# - ``qtwebengine_devtools_resources.pak``
|
# - ``qtwebengine_resources.pak``
|
# - ``qtwebengine_resources_100p.pak``
|
# - ``qtwebengine_resources_200p.pak``
|
#
|
# This is handled by the ``QtWebEngineCore`` hook, for example ``hook-PyQt5.QtWebEngineCore.py``.
|
#
|
# For details and references, see the `original write-up
|
# <https://github.com/pyinstaller/pyinstaller/blob/fbf7948be85177dd44b41217e9f039e1d176de6b/PyInstaller/utils/hooks/qt.py#L248-L362>`_
|
# and the documentation in the ``_modules_info`` module.
|
|
|
# QtModuleInfo
|
# ------------
|
# This class contains information about python module (extension), its corresponding Qt module (shared library), and
|
# associated plugins and translations. It is used within QtLibraryInfo_ to establish name-based mappings for file
|
# collection.
|
class QtModuleInfo:
|
def __init__(self, module, shared_lib=None, translation=None, plugins=None):
|
# Python module (extension) name without package namespace. For example, `QtCore`.
|
# Can be None if python bindings do not bind the module, but we still need to establish relationship between
|
# the Qt module (shared library) and its plugins and translations.
|
self.module = module
|
# Associated Qt module (shared library), if any. Used during recursive dependency analysis, where a python
|
# module (extension) is analyzed for linked Qt modules (shared libraries), and then their corresponding
|
# python modules (extensions) are added to hidden imports. For example, the Qt module name is `Qt5Core` or
|
# `Qt6Core`, depending on the Qt version. Can be None for python modules that are not tied to a particular
|
# Qt shared library (for example, the corresponding Qt module is headers-only) and hence they cannot be
|
# inferred from recursive link-time dependency analysis.
|
self.shared_lib = shared_lib
|
# Base name of translation files (if any) associated with the Qt module. For example, `qtcore`.
|
self.translation = translation
|
# List of plugins associated with the Qt module.
|
self.plugins = plugins or []
|
|
def __repr__(self):
|
return f"(module={self.module!r}, shared_lib={self.shared_lib!r}, " \
|
f"translation={self.translation!r}, plugins={self.plugins!r}"
|
|
|
# QtLibraryInfo
|
# --------------
|
# This class uses introspection to determine the location of Qt files. This is essential to deal with the many variants
|
# of the PyQt5/6 and PySide2/6 package, each of which places files in a different location. Therefore, this class
|
# provides all location-related members of `QLibraryInfo <http://doc.qt.io/qt-5/qlibraryinfo.html>`_.
|
class QtLibraryInfo:
|
def __init__(self, namespace):
|
if namespace not in ['PyQt5', 'PyQt6', 'PySide2', 'PySide6']:
|
raise Exception('Invalid namespace: {0}'.format(namespace))
|
self.namespace = namespace
|
# Distinction between PyQt5/6 and PySide2/6
|
self.is_pyqt = namespace in {'PyQt5', 'PyQt6'}
|
# Distinction between Qt5 and Qt6
|
self.qt_major = 6 if namespace in {'PyQt6', 'PySide6'} else 5
|
# Determine relative path where Qt libraries and data need to be collected in the frozen application. This
|
# varies between PyQt5/PyQt6/PySide2/PySide6, their versions, and platforms. NOTE: it is tempting to consider
|
# deriving this path as simply the value of QLibraryInfo.PrefixPath, taken relative to the package's root
|
# directory. However, we also need to support non-wheel deployments (e.g., with Qt installed in custom path on
|
# Windows, or with Qt and PyQt5 installed on linux using native package manager), and in those, the Qt
|
# PrefixPath does not reflect the required relative target path for the frozen application.
|
if namespace == 'PyQt5':
|
# PyQt5 uses PyQt5/Qt on all platforms, or PyQt5/Qt5 from version 5.15.4 on
|
try:
|
# The call below might fail with AttributeError on some PyQt5 versions (e.g., 5.9.2 from conda's main
|
# channel); missing dist information forces a fallback codepath that tries to check for __version__
|
# attribute that does not exist, either. So handle the error gracefully and assume old layout.
|
new_layout = hooks.is_module_satisfies("PyQt5 >= 5.15.4")
|
except AttributeError:
|
new_layout = False
|
if new_layout:
|
self.qt_rel_dir = os.path.join('PyQt5', 'Qt5')
|
else:
|
self.qt_rel_dir = os.path.join('PyQt5', 'Qt')
|
elif namespace == 'PyQt6':
|
# Similarly to PyQt5, PyQt6 switched from PyQt6/Qt to PyQt6/Qt6 in 6.0.3
|
try:
|
# The call below might fail with AttributeError in case of a partial PyQt6 installation. For example,
|
# user installs PyQt6 via pip, which also installs PyQt6-Qt6 and PyQt6-sip. Then they naively uninstall
|
# PyQt6 package, which leaves the other two behind. PyQt6 now becomes a namespace package and there is
|
# no dist metadata, so a fallback codepath in is_module_satisfies tries to check for __version__
|
# attribute that does not exist, either. Handle such errors gracefully and assume new layout (with
|
# PyQt6, the new layout is more likely); it does not really matter what layout we assume, as library is
|
# not usable anyway, but we do need to be able to return an instance of QtLibraryInfo with "version"
|
# attribute set to a falsey value.
|
new_layout = hooks.is_module_satisfies("PyQt6 >= 6.0.3")
|
except AttributeError:
|
new_layout = True
|
if new_layout:
|
self.qt_rel_dir = os.path.join('PyQt6', 'Qt6')
|
else:
|
self.qt_rel_dir = os.path.join('PyQt6', 'Qt')
|
elif namespace == 'PySide2':
|
# PySide2 uses PySide2/Qt on linux and macOS, and PySide2 on Windows
|
if compat.is_win:
|
self.qt_rel_dir = 'PySide2'
|
else:
|
self.qt_rel_dir = os.path.join('PySide2', 'Qt')
|
else:
|
# PySide6 follows the same logic as PySide2
|
if compat.is_win:
|
self.qt_rel_dir = 'PySide6'
|
else:
|
self.qt_rel_dir = os.path.join('PySide6', 'Qt')
|
|
# Process module information list to construct python-module-name -> info and shared-lib-name -> info mappings.
|
self._load_module_info()
|
|
def __repr__(self):
|
return f"QtLibraryInfo({self.namespace})"
|
|
# Delay initialization of the Qt library information until the corresponding attributes are first requested.
|
def __getattr__(self, name):
|
if 'version' in self.__dict__:
|
# Initialization was already done, but requested attribute is not available.
|
raise AttributeError(name)
|
|
# Load Qt library info...
|
self._load_qt_info()
|
# ... and return the requested attribute
|
return getattr(self, name)
|
|
# Load Qt information (called on first access to related fields)
|
def _load_qt_info(self):
|
"""
|
Load and process Qt library information. Called on the first access to the related attributes of the class
|
(e.g., `version` or `location`).
|
"""
|
|
# Ensure self.version exists, even if PyQt{5,6}/PySide{2,6} cannot be imported. Hooks and util functions use
|
# `if .version` to check whether package was imported and other attributes are expected to be available.
|
# This also serves as a marker that initialization was already done.
|
self.version = None
|
|
# Get library path information from Qt. See QLibraryInfo_.
|
@isolated.decorate
|
def _read_qt_library_info(package):
|
import os
|
import sys
|
import importlib
|
|
# Import the Qt-based package
|
# equivalent to: from package.QtCore import QLibraryInfo, QCoreApplication
|
QtCore = importlib.import_module('.QtCore', package)
|
QLibraryInfo = QtCore.QLibraryInfo
|
QCoreApplication = QtCore.QCoreApplication
|
|
# QLibraryInfo is not always valid until a QCoreApplication is instantiated.
|
app = QCoreApplication(sys.argv) # noqa: F841
|
|
# Qt6 deprecated QLibraryInfo.location() in favor of QLibraryInfo.path(), and
|
# QLibraryInfo.LibraryLocation enum was replaced by QLibraryInfo.LibraryPath.
|
if hasattr(QLibraryInfo, 'path'):
|
# Qt6; enumerate path enum values directly from the QLibraryInfo.LibraryPath enum.
|
path_names = [x for x in dir(QLibraryInfo.LibraryPath) if x.endswith('Path')]
|
location = {x: QLibraryInfo.path(getattr(QLibraryInfo.LibraryPath, x)) for x in path_names}
|
else:
|
# Qt5; in recent versions, location enum values can be enumeratd from QLibraryInfo.LibraryLocation.
|
# However, in older versions of Qt5 and its python bindings, that is unavailable. Hence the
|
# enumeration of "*Path"-named members of QLibraryInfo.
|
path_names = [x for x in dir(QLibraryInfo) if x.endswith('Path')]
|
location = {x: QLibraryInfo.location(getattr(QLibraryInfo, x)) for x in path_names}
|
|
# Determine the python-based package location, by looking where the QtCore module is located.
|
package_location = os.path.dirname(QtCore.__file__)
|
|
# Determine Qt version. Works for Qt 5.8 and later, where QLibraryInfo.version() was introduced.
|
try:
|
version = QLibraryInfo.version().segments()
|
except AttributeError:
|
version = []
|
|
return {
|
'is_debug_build': QLibraryInfo.isDebugBuild(),
|
'version': version,
|
'location': location,
|
'package_location': package_location,
|
}
|
|
try:
|
qt_info = _read_qt_library_info(self.namespace)
|
except Exception as e:
|
logger.warning("%s: failed to obtain Qt library info: %s", self, e)
|
qt_info = {}
|
|
for k, v in qt_info.items():
|
setattr(self, k, v)
|
|
# Module information list loading/processing
|
def _load_module_info(self):
|
"""
|
Process the Qt modules info definition list and construct two dictionaries:
|
- dictionary that maps Qt python module names to Qt module info entries
|
- dictionary that maps shared library names to Qt module info entries
|
"""
|
|
self.python_modules = dict()
|
self.shared_libraries = dict()
|
|
for entry in _modules_info.QT_MODULES_INFO:
|
# If entry specifies applicable bindings, check them
|
if entry.bindings:
|
applicable_bindings = _modules_info.process_namespace_strings(entry.bindings)
|
if self.namespace not in applicable_bindings:
|
continue
|
|
# Create a QtModuleInfo entry
|
info_entry = QtModuleInfo(
|
module=entry.module,
|
shared_lib=f"Qt{self.qt_major}{entry.shared_lib}" if entry.shared_lib else None,
|
translation=entry.translation,
|
plugins=entry.plugins
|
)
|
|
# If we have python module (extension) name, create python-module-name -> info mapping.
|
if info_entry.module is not None:
|
self.python_modules[info_entry.module] = info_entry
|
|
# If we have Qt module (shared library) name, create shared-lib-name -> info mapping.
|
if info_entry.shared_lib is not None:
|
self.shared_libraries[info_entry.shared_lib.lower()] = info_entry
|
|
# Collection
|
def collect_module(self, module_name):
|
"""
|
Collect all dependencies (hiddenimports, binaries, datas) for the given Qt python module.
|
|
This function performs recursive analysis of extension module's link-time dependencies, and uses dictionaries
|
built by `_load_module_info` to discover associated plugin types, translation file base names, and hidden
|
imports that need to be collected.
|
"""
|
|
# Accumulate all dependencies in a set to avoid duplicates.
|
hiddenimports = set()
|
translation_base_names = set()
|
plugin_types = set()
|
|
# Exit if the requested library cannot be imported.
|
# NOTE: self..version can be empty list on older Qt5 versions (#5381).
|
if self.version is None:
|
return [], [], []
|
|
logger.debug('%s: processing module %s...', self, module_name)
|
|
# Look up the associated Qt module information by python module name.
|
# This allows us to collect associated module data directly, even if there is no associated shared library
|
# (e.g., header-only Qt module, or statically-built one).
|
short_module_name = module_name.split('.', 1)[-1] # PySide2.QtModule -> QtModule
|
if short_module_name in self.python_modules:
|
qt_module_info = self.python_modules[short_module_name]
|
|
# NOTE: no need to add a hiddenimport here, because this is the module under consideration.
|
|
# Add plugins
|
plugin_types.update(qt_module_info.plugins)
|
|
# Add translation base name
|
if qt_module_info.translation:
|
translation_base_names.add(qt_module_info.translation)
|
|
# Find the actual module extension file.
|
module_file = hooks.get_module_file_attribute(module_name)
|
|
# Walk through all the link-time dependencies of a dynamically-linked library (``.so``/``.dll``/``.dylib``).
|
imported_libraries = set(bindepend.getImports(module_file))
|
while imported_libraries:
|
imported_library = imported_libraries.pop()
|
|
# On Windows, resolve the shared library's full path; other platforms already provide the full path.
|
if compat.is_win:
|
# First, look for Qt binaries in the local Qt install. For PyQt5 and PyQt6, DLLs should be in
|
# BinariesPath, while for PySide2 and PySide6, they should be in PrefixPath.
|
dll_location = self.location['BinariesPath' if self.is_pyqt else 'PrefixPath']
|
imported_library = bindepend.getfullnameof(imported_library, dll_location)
|
|
# Strip off the extension and ``lib`` prefix (Linux/Mac) to give the raw name.
|
# Lowercase (since Windows always normalizes names to lowercase).
|
lib_name = os.path.splitext(os.path.basename(imported_library))[0].lower()
|
# Linux libraries sometimes have a dotted version number -- ``libfoo.so.3``. It is now ''libfoo.so``,
|
# but the ``.so`` must also be removed.
|
if compat.is_linux and os.path.splitext(lib_name)[1] == '.so':
|
lib_name = os.path.splitext(lib_name)[0]
|
if lib_name.startswith('lib'):
|
lib_name = lib_name[3:]
|
# Mac OS: handle different naming schemes. PyPI wheels ship framework-enabled Qt builds, where shared
|
# libraries are part of .framework bundles (e.g., ``PyQt5/Qt5/lib/QtCore.framework/Versions/5/QtCore``).
|
# In Anaconda (Py)Qt installations, the shared libraries are installed in environment's library directory,
|
# and contain versioned extensions, e.g., ``libQt5Core.5.dylib``.
|
if compat.is_darwin:
|
if lib_name.startswith('qt') and not lib_name.startswith('qt' + str(self.qt_major)):
|
# Qt library from a framework bundle (e.g., ``QtCore``); change prefix from ``qt`` to ``qt5`` or
|
# ``qt6`` to match names in Windows/Linux.
|
lib_name = 'qt' + str(self.qt_major) + lib_name[2:]
|
if lib_name.endswith('.' + str(self.qt_major)):
|
# Qt library from Anaconda, which originally had versioned extension, e.g., ``libfoo.5.dynlib``.
|
# The above processing turned it into ``foo.5``, so we need to remove the last two characters.
|
lib_name = lib_name[:-2]
|
|
# Match libs with QT_LIBINFIX set to '_conda', i.e. conda-forge builds.
|
if lib_name.endswith('_conda'):
|
lib_name = lib_name[:-6]
|
|
logger.debug('%s: raw imported library name %s -> parsed name %s.', self, imported_library, lib_name)
|
|
# PySide2 and PySide6 on linux seem to link all extension modules against libQt5Core, libQt5Network, and
|
# libQt5Qml (or their libQt6* equivalents). While the first two are reasonable, the libQt5Qml dependency
|
# pulls in whole QtQml module, along with its data and plugins, which in turn pull in several other Qt
|
# libraries, greatly inflating the bundle size (see #6447).
|
#
|
# Similarly, some extension modules (QtWebChannel, QtWebEngine*) seem to be also linked against libQt5Qml,
|
# even when the module can be used without having the whole QtQml module collected.
|
#
|
# Therefore, we explicitly prevent inclusion of QtQml based on the dynamic library dependency, except for
|
# QtQml* and QtQuick* modules, whose use directly implies the use of QtQml.
|
if lib_name in ("qt5qml", "qt6qml"):
|
if not short_module_name.startswith(('QtQml', 'QtQuick')):
|
logger.debug('%s: ignoring imported library %s.', self, imported_library)
|
continue
|
|
# Use the parsed library name to look up associated Qt module information.
|
if lib_name in self.shared_libraries:
|
logger.debug(
|
'%s: collecting Qt module associated with imported shared library %s (%s).', self, imported_library,
|
lib_name
|
)
|
|
# Look up associated module info
|
qt_module_info = self.shared_libraries[lib_name]
|
|
# If there is a python extension module associated with Qt module, add it to hiddenimports. Since this
|
# means that we (most likely) have a hook available for that module, we can avoid analyzing the shared
|
# library itself (i.e., stop the recursive analysis), because this will be done by the corresponding
|
# hook.
|
if qt_module_info.module:
|
if qt_module_info.module == short_module_name:
|
# The one exception is if we are analyzing shared library associated with the input module; in
|
# that case, avoid adding a hidden import and analyze the library's link-time dependencies. We
|
# do not need to worry about plugins and translations for this particular module, because those
|
# have been handled at the beginning of this function.
|
imported_libraries.update(bindepend.getImports(imported_library))
|
else:
|
hiddenimports.add(self.namespace + "." + qt_module_info.module)
|
continue
|
|
# Add plugins
|
plugin_types.update(qt_module_info.plugins)
|
|
# Add translation base name
|
if qt_module_info.translation:
|
translation_base_names.add(qt_module_info.translation)
|
|
# Analyze the linked shared libraries for its dependencies (recursive analysis).
|
imported_libraries.update(bindepend.getImports(imported_library))
|
|
# Collect plugin files.
|
binaries = []
|
for plugin_type in plugin_types:
|
binaries += self.collect_plugins(plugin_type)
|
|
# Collect translation files.
|
datas = []
|
translation_src = self.location['TranslationsPath']
|
translation_dst = os.path.join(self.qt_rel_dir, 'translations')
|
|
for translation_base_name in translation_base_names:
|
# Not all PyQt5 installations include translations. See
|
# https://github.com/pyinstaller/pyinstaller/pull/3229#issuecomment-359479893
|
# and
|
# https://github.com/pyinstaller/pyinstaller/issues/2857#issuecomment-368744341.
|
translation_pattern = os.path.join(translation_src, translation_base_name + '_*.qm')
|
translation_files = glob.glob(translation_pattern)
|
if translation_files:
|
datas += [(translation_file, translation_dst) for translation_file in translation_files]
|
else:
|
logger.warning(
|
'%s: could not find translations with base name %r! These translations will not be collected.',
|
self, translation_base_name
|
)
|
|
# Convert hiddenimports to a list.
|
hiddenimports = list(hiddenimports)
|
|
logger.debug(
|
'%s: dependencies for %s:\n'
|
' hiddenimports = %r\n'
|
' binaries = %r\n'
|
' datas = %r', self, module_name, hiddenimports, binaries, datas
|
)
|
|
return hiddenimports, binaries, datas
|
|
@staticmethod
|
def _filter_release_plugins(plugin_files):
|
"""
|
Filter the provided list of Qt plugin files and remove the debug variants, under the assumption that both the
|
release version of a plugin (qtplugin.dll) and its debug variant (qtplugind.dll) appear in the list.
|
"""
|
# All basenames for lookup
|
plugin_basenames = {os.path.normcase(os.path.basename(f)) for f in plugin_files}
|
# Process all given filenames
|
release_plugin_files = []
|
for plugin_filename in plugin_files:
|
plugin_basename = os.path.normcase(os.path.basename(plugin_filename))
|
if plugin_basename.endswith('d.dll'):
|
# If we can find a variant without trailing 'd' in the plugin list, then the DLL we are dealing with is
|
# a debug variant and needs to be excluded.
|
release_name = os.path.splitext(plugin_basename)[0][:-1] + '.dll'
|
if release_name in plugin_basenames:
|
continue
|
release_plugin_files.append(plugin_filename)
|
return release_plugin_files
|
|
def collect_plugins(self, plugin_type):
|
"""
|
Collect all plugins of the specified type from the Qt plugin directory.
|
|
Returns list of (src, dst) tuples.
|
"""
|
# Ensure plugin directory exists
|
plugin_src_dir = self.location['PluginsPath']
|
if not os.path.isdir(plugin_src_dir):
|
raise Exception(f"Qt plugin directory '{plugin_src_dir}' does not exist!")
|
|
# Collect all shared lib files in plugin type (sub)directory
|
plugin_files = misc.dlls_in_dir(os.path.join(plugin_src_dir, plugin_type))
|
|
# Windows:
|
#
|
# dlls_in_dir() grabs all files ending with ``*.dll``, ``*.so`` and ``*.dylib`` in a certain directory. On
|
# Windows this would grab debug copies of Qt plugins, which then causes PyInstaller to add a dependency on the
|
# Debug CRT *in addition* to the release CRT.
|
if compat.is_win:
|
plugin_files = self._filter_release_plugins(plugin_files)
|
|
logger.debug("%s: found plugin files for plugin type %r: %r", self, plugin_type, plugin_files)
|
|
plugin_dst_dir = os.path.join(self.qt_rel_dir, 'plugins', plugin_type)
|
return [(plugin_file, plugin_dst_dir) for plugin_file in plugin_files]
|
|
def _collect_all_or_none(self, mandatory_dll_patterns, optional_dll_patterns=None):
|
"""
|
Try to find Qt DLLs from the specified mandatory pattern list. If all mandatory patterns resolve to DLLs,
|
collect them all, as well as any DLLs from the optional pattern list. If a mandatory pattern fails to resolve
|
to a DLL, return an empty list.
|
|
This allows all-or-none collection of particular groups of Qt DLLs that may or may not be available.
|
"""
|
optional_dll_patterns = optional_dll_patterns or []
|
|
# Resolve path to the the corresponding python package (actually, its parent directory). Used to preserve the
|
# directory structure when DLLs are collected from the python package (e.g., PyPI wheels).
|
package_parent_path = pathlib.Path(self.package_location).resolve().parent
|
|
# In PyQt5 and PyQt6, the DLLs we are looking for are located in location['BinariesPath'], whereas in PySide2
|
# and PySide6, they are located in location['PrefixPath'].
|
dll_path = self.location['BinariesPath' if self.is_pyqt else 'PrefixPath']
|
dll_path = pathlib.Path(dll_path).resolve()
|
|
# Helper for processing single DLL pattern
|
def _process_dll_pattern(dll_pattern):
|
discovered_dlls = []
|
|
dll_files = dll_path.glob(dll_pattern)
|
for dll_file in dll_files:
|
if package_parent_path in dll_file.parents:
|
# The DLL is located within python package; preserve the layout
|
dst_dll_dir = dll_file.parent.relative_to(package_parent_path)
|
else:
|
# The DLL is not located within python package; collect into top-level directory
|
dst_dll_dir = '.'
|
discovered_dlls.append((str(dll_file), str(dst_dll_dir)))
|
|
return discovered_dlls
|
|
# Process mandatory patterns
|
collected_dlls = []
|
for pattern in mandatory_dll_patterns:
|
discovered_dlls = _process_dll_pattern(pattern)
|
if not discovered_dlls:
|
return [] # Mandatory pattern resulted in no DLLs; abort
|
collected_dlls += discovered_dlls
|
|
# Process optional patterns
|
for pattern in optional_dll_patterns:
|
collected_dlls += _process_dll_pattern(pattern)
|
|
return collected_dlls
|
|
# Collect required Qt binaries, but only if all binaries in a group exist.
|
def collect_extra_binaries(self):
|
"""
|
Collect extra binaries/DLLs required by Qt. These include ANGLE DLLs, OpenGL software renderer DLL, and ICU
|
DLLs. Applicable only on Windows (on other OSes, empty list is returned).
|
"""
|
|
binaries = []
|
|
# Applicable only to Windows.
|
if not compat.is_win:
|
return []
|
|
# OpenGL: EGL/GLES via ANGLE, software OpenGL renderer.
|
binaries += self._collect_all_or_none(['libEGL.dll', 'libGLESv2.dll'], ['d3dcompiler_??.dll'])
|
binaries += self._collect_all_or_none(['opengl32sw.dll'])
|
|
# Include ICU files, if they exist.
|
# See the "Deployment approach" section in ``PyInstaller/utils/hooks/qt.py``.
|
binaries += self._collect_all_or_none(['icudt??.dll', 'icuin??.dll', 'icuuc??.dll'])
|
|
return binaries
|
|
# Collect additional shared libraries required for SSL support in QtNetwork, if they are available.
|
# Applicable only to Windows. See issue #3520, #4048.
|
def collect_qtnetwork_files(self):
|
"""
|
Collect extra binaries/DLLs required by the QtNetwork module. These include OpenSSL DLLs. Applicable only
|
on Windows (on other OSes, empty list is returned).
|
"""
|
|
# No-op if requested Qt-based package is not available.
|
if self.version is None:
|
return []
|
|
# Applicable only to Windows.
|
if not compat.is_win:
|
return []
|
|
# Check if QtNetwork supports SSL
|
@isolated.decorate
|
def _ssl_enabled(package):
|
import sys
|
import importlib
|
|
# Import the Qt-based package
|
# equivalent to: from package.QtCore import QCoreApplication
|
QtCore = importlib.import_module('.QtCore', package)
|
QCoreApplication = QtCore.QCoreApplication
|
# equivalent to: from package.QtNetwork import QSslSocket
|
QtNetwork = importlib.import_module('.QtNetwork', package)
|
QSslSocket = QtNetwork.QSslSocket
|
|
# Instantiate QCoreApplication to suppress warnings
|
app = QCoreApplication(sys.argv) # noqa: F841
|
|
return QSslSocket.supportsSsl()
|
|
if not _ssl_enabled(self.namespace):
|
return []
|
|
# Resolve path to the the corresponding python package (actually, its parent directory). Used to preserve the
|
# directory structure when DLLs are collected from the python package (e.g., PyPI wheels).
|
package_parent_path = pathlib.Path(self.package_location).resolve().parent
|
|
# PyPI version of PySide2 requires user to manually install SSL libraries into the PrefixPath. Other versions
|
# (e.g., the one provided by Conda) put the libraries into the BinariesPath. PyQt5 also uses BinariesPath.
|
# Accommodate both options by searching both locations...
|
locations = (self.location['BinariesPath'], self.location['PrefixPath'])
|
dll_names = ('libeay32.dll', 'ssleay32.dll', 'libssl-1_1-x64.dll', 'libcrypto-1_1-x64.dll')
|
binaries = []
|
for location in locations:
|
location = pathlib.Path(location).resolve()
|
for dll in dll_names:
|
dll_file_path = location / dll
|
if not dll_file_path.exists():
|
continue
|
if package_parent_path in dll_file_path.parents:
|
# The DLL is located within python package; preserve the layout
|
dst_dll_path = dll_file_path.parent.relative_to(package_parent_path)
|
else:
|
# The DLL is not located within python package; collect into top-level directory
|
dst_dll_path = '.'
|
binaries.append((str(dll_file_path), str(dst_dll_path)))
|
return binaries
|
|
def collect_qtqml_files(self):
|
"""
|
Collect additional binaries and data for QtQml module.
|
"""
|
|
# No-op if requested Qt-based package is not available.
|
if self.version is None:
|
return [], []
|
|
# Not all PyQt5/PySide2 installs have QML files. In this case, location['Qml2ImportsPath'] is empty.
|
# Furthermore, even if location path is provided, the directory itself may not exist.
|
#
|
# https://github.com/pyinstaller/pyinstaller/pull/3229#issuecomment-359735031
|
# https://github.com/pyinstaller/pyinstaller/issues/3864
|
#
|
# In Qt 6, Qml2ImportsPath was deprecated in favor of QmlImportsPath. The former is not available in PySide6
|
# 6.4.0 anymore (but is in PyQt6 6.4.0). Use the new QmlImportsPath if available.
|
if 'QmlImportsPath' in self.location:
|
qml_src_dir = self.location['QmlImportsPath']
|
else:
|
qml_src_dir = self.location['Qml2ImportsPath']
|
if not qml_src_dir or not os.path.isdir(qml_src_dir):
|
logger.warning('%s: QML directory %r does not exist. QML files not packaged.', self, qml_src_dir)
|
return [], []
|
|
qml_dst_dir = os.path.join(self.qt_rel_dir, 'qml')
|
datas = [(qml_src_dir, qml_dst_dir)]
|
binaries = [
|
# Produce ``/path/to/Qt/Qml/path_to_qml_binary/qml_binary, PyQt5/Qt/Qml/path_to_qml_binary``.
|
(
|
qml_plugin_file,
|
os.path.join(qml_dst_dir, os.path.dirname(os.path.relpath(qml_plugin_file, qml_src_dir)))
|
) for qml_plugin_file in misc.dlls_in_subdirs(qml_src_dir)
|
]
|
|
return binaries, datas
|
|
def collect_qtwebengine_files(self):
|
"""
|
Collect QtWebEngine helper process executable, translations, and resources.
|
"""
|
|
binaries = []
|
datas = []
|
|
# Output directory (varies between PyQt and PySide and among OSes; the difference is abstracted by
|
# QtLibraryInfo.qt_rel_dir)
|
rel_data_path = self.qt_rel_dir
|
|
is_macos_framework = False
|
if compat.is_darwin:
|
# Determine if we are dealing with a framework-based Qt build (e.g., PyPI wheels) or a dylib-based one
|
# (e.g., Anaconda). The former requires special handling, while the latter is handled in the same way as
|
# Windows and Linux builds.
|
is_macos_framework = os.path.exists(
|
os.path.join(self.location['LibrariesPath'], 'QtWebEngineCore.framework')
|
)
|
|
if is_macos_framework:
|
# On macOS, Qt shared libraries are provided in form of .framework bundles. However, PyInstaller collects
|
# shared library from the bundle into top-level application directory, breaking the bundle structure.
|
#
|
# QtWebEngine and its underlying Chromium engine, however, have very strict data file layout requirements
|
# due to sandboxing, and does not work if the helper process executable does not load the shared library
|
# from QtWebEngineCore.framework (which also needs to contain all resources).
|
#
|
# Therefore, we collect the QtWebEngineCore.framework manually, in order to obtain a working
|
# QtWebEngineProcess helper executable. But because that bypasses our dependency scanner, we need to collect
|
# the dependent .framework bundles as well. And we need to override QTWEBENGINEPROCESS_PATH in rthook,
|
# because the QtWebEngine python extensions actually load up the copy of shared library that is located in
|
# sys._MEIPASS (as opposed to the manually-copied one in .framework bundle). Furthermore, because the
|
# extension modules use Qt shared libraries in sys._MEIPASS, we also copy all contents of
|
# QtWebEngineCore.framework/Resources into sys._MEIPASS to make resource loading in the main process work.
|
#
|
# Besides being ugly, this approach has three main ramifications:
|
# 1. we bundle two copies of each Qt shared library involved: the copy used by main process, picked up by
|
# dependency scanner; and a copy in manually-collected .framework bundle that is used by the helper
|
# process.
|
# 2. the trick with copying contents of Resource directory of QtWebEngineCore.framework does not work in
|
# onefile mode, and consequently QtWebEngine does not work in onefile mode.
|
# 3. copying contents of QtWebEngineCore.framework/Resource means that its Info.plist ends up in
|
# sys._MEIPASS, causing the main process in onedir mode to be mis-identified as "QtWebEngineProcess".
|
#
|
# In the near future, this quagmire will hopefully be properly sorted out, but in the mean time, we have to
|
# live with what we have been given.
|
data_path = self.location['DataPath']
|
libraries = [
|
'QtCore', 'QtWebEngineCore', 'QtQuick', 'QtQml', 'QtQmlModels', 'QtNetwork', 'QtGui', 'QtWebChannel',
|
'QtPositioning'
|
]
|
if self.qt_major == 6:
|
libraries.extend(['QtOpenGL', 'QtDBus'])
|
for i in libraries:
|
framework_dir = i + '.framework'
|
framework_src_path = os.path.join(data_path, 'lib', framework_dir)
|
framework_dst_path = os.path.join(rel_data_path, 'lib', framework_dir)
|
datas += hooks.collect_system_data_files(framework_src_path, framework_dst_path, True)
|
datas += [(os.path.join(data_path, 'lib', 'QtWebEngineCore.framework', 'Resources'), '.')]
|
else:
|
# Windows and linux (or Anaconda on macOS)
|
locales = 'qtwebengine_locales'
|
resources = 'resources'
|
|
# Translations
|
datas.append((
|
os.path.join(self.location['TranslationsPath'], locales),
|
os.path.join(rel_data_path, 'translations', locales),
|
))
|
|
# Resources; ``DataPath`` is the base directory for ``resources``, as per the
|
# `docs <https://doc.qt.io/qt-5.10/qtwebengine-deploying.html#deploying-resources>`_.
|
datas.append((os.path.join(self.location['DataPath'], resources), os.path.join(rel_data_path, resources)),)
|
|
# Helper process executable (QtWebEngineProcess), located in ``LibraryExecutablesPath``.
|
dest = os.path.join(
|
rel_data_path, os.path.relpath(self.location['LibraryExecutablesPath'], self.location['PrefixPath'])
|
)
|
binaries.append((os.path.join(self.location['LibraryExecutablesPath'], 'QtWebEngineProcess*'), dest))
|
|
# The helper QtWebEngineProcess executable should have an accompanying qt.conf file that helps it locate the
|
# Qt shared libraries. Try collecting it as well
|
qt_conf_file = os.path.join(self.location['LibraryExecutablesPath'], 'qt.conf')
|
if not os.path.isfile(qt_conf_file):
|
# The file seems to have been dropped from Qt 6.3 (and corresponding PySide6 and PyQt6) due to
|
# redundancy; however, we still need it in the frozen application - so generate our own.
|
from PyInstaller.config import CONF # workpath
|
# Relative path to root prefix of bundled Qt
|
rel_prefix = os.path.relpath(self.location['PrefixPath'], self.location['LibraryExecutablesPath'])
|
# We expect the relative path to be either . or .. depending on PySide/PyQt layout; if that is not the
|
# case, warn about irregular path.
|
if rel_prefix not in ('.', '..'):
|
logger.warning(
|
"%s: unexpected relative Qt prefix path for QtWebEngineProcess qt.conf: %s", self, rel_prefix
|
)
|
# The Qt docs on qt.conf (https://doc.qt.io/qt-5/qt-conf.html) recommend using forward slashes on
|
# Windows as well, due to backslash having to be escaped. This should not matter as we expect the
|
# relative path to be . or .., but you never know...
|
if os.sep == '\\':
|
rel_prefix = rel_prefix.replace(os.sep, '/')
|
# Create temporary file in workpath
|
qt_conf_file = os.path.join(CONF['workpath'], "qt.conf")
|
with open(qt_conf_file, 'w') as fp:
|
print("[Paths]", file=fp)
|
print("Prefix = {}".format(rel_prefix), file=fp)
|
datas.append((qt_conf_file, dest))
|
|
# Add Linux-specific libraries.
|
if compat.is_linux:
|
# The automatic library detection fails for `NSS <https://packages.ubuntu.com/search?keywords=libnss3>`_,
|
# which is used by QtWebEngine. In some distributions, the ``libnss`` supporting libraries are stored in a
|
# subdirectory ``nss``. Since ``libnss`` is not statically linked to these, but dynamically loads them, we
|
# need to search for and add them.
|
|
# First, get all libraries linked to ``QtWebEngineCore`` extension module.
|
module_file = hooks.get_module_file_attribute(self.namespace + '.QtWebEngineCore')
|
module_imports = bindepend.getImports(module_file)
|
for imp in module_imports:
|
# Look for ``libnss3.so``.
|
if os.path.basename(imp).startswith('libnss3.so'):
|
# Find the location of NSS: given a ``/path/to/libnss.so``, add ``/path/to/nss/*.so`` to get the
|
# missing NSS libraries.
|
nss_glob = os.path.join(os.path.dirname(imp), 'nss', '*.so')
|
if glob.glob(nss_glob):
|
binaries.append((nss_glob, 'nss'))
|
|
return binaries, datas
|
|
|
# Provide single instances of this class to avoid each hook constructing its own.
|
pyqt5_library_info = QtLibraryInfo('PyQt5')
|
pyqt6_library_info = QtLibraryInfo('PyQt6')
|
pyside2_library_info = QtLibraryInfo('PySide2')
|
pyside6_library_info = QtLibraryInfo('PySide6')
|
|
|
def get_qt_library_info(namespace):
|
"""
|
Return QtLibraryInfo instance for the given namespace.
|
"""
|
if namespace == 'PyQt5':
|
return pyqt5_library_info
|
if namespace == 'PyQt6':
|
return pyqt6_library_info
|
elif namespace == 'PySide2':
|
return pyside2_library_info
|
elif namespace == 'PySide6':
|
return pyside6_library_info
|
|
raise ValueError(f'Invalid namespace: {namespace}!')
|
|
|
# add_qt_dependencies
|
# --------------------
|
# Generic implemnentation that finds the Qt 5/6 dependencies based on the hook name of a PyQt5/PyQt6/PySide2/PySide6
|
# hook. Returns (hiddenimports, binaries, datas). Typical usage:
|
# ``hiddenimports, binaries, datas = add_qt5_dependencies(__file__)``.
|
def add_qt_dependencies(hook_file):
|
# Find the module underlying this Qt hook: change ``/path/to/hook-PyQt5.blah.py`` to ``PyQt5.blah``.
|
hook_name, hook_ext = os.path.splitext(os.path.basename(hook_file))
|
assert hook_ext.startswith('.py')
|
assert hook_name.startswith('hook-')
|
module_name = hook_name[5:]
|
namespace = module_name.split('.')[0]
|
|
# Retrieve Qt library info structure....
|
qt_info = get_qt_library_info(namespace)
|
# ... and use it to collect module dependencies
|
return qt_info.collect_module(module_name)
|
|
|
# add_qt5_dependencies
|
# --------------------
|
# Find the Qt5 dependencies based on the hook name of a PySide2/PyQt5 hook. Returns (hiddenimports, binaries, datas).
|
# Typical usage: ``hiddenimports, binaries, datas = add_qt5_dependencies(__file__)``.
|
add_qt5_dependencies = add_qt_dependencies # Use generic implementation
|
|
# add_qt6_dependencies
|
# --------------------
|
# Find the Qt6 dependencies based on the hook name of a PySide6/PyQt6 hook. Returns (hiddenimports, binaries, datas).
|
# Typical usage: ``hiddenimports, binaries, datas = add_qt6_dependencies(__file__)``.
|
add_qt6_dependencies = add_qt_dependencies # Use generic implementation
|