aboutsummaryrefslogtreecommitdiffstats
path: root/sources/pyside-tools/deploy_lib
diff options
context:
space:
mode:
Diffstat (limited to 'sources/pyside-tools/deploy_lib')
-rw-r--r--sources/pyside-tools/deploy_lib/__init__.py59
-rw-r--r--sources/pyside-tools/deploy_lib/android/__init__.py16
-rw-r--r--sources/pyside-tools/deploy_lib/android/android_config.py446
-rw-r--r--sources/pyside-tools/deploy_lib/android/android_helper.py151
-rw-r--r--sources/pyside-tools/deploy_lib/android/buildozer.py147
-rw-r--r--sources/pyside-tools/deploy_lib/android/recipes/PySide6/__init__.tmpl.py64
-rw-r--r--sources/pyside-tools/deploy_lib/android/recipes/shiboken6/__init__.tmpl.py31
-rw-r--r--sources/pyside-tools/deploy_lib/commands.py60
-rw-r--r--sources/pyside-tools/deploy_lib/config.py459
-rw-r--r--sources/pyside-tools/deploy_lib/default.spec97
-rw-r--r--sources/pyside-tools/deploy_lib/dependency_util.py319
-rw-r--r--sources/pyside-tools/deploy_lib/deploy_util.py77
-rw-r--r--sources/pyside-tools/deploy_lib/nuitka_helper.py118
-rw-r--r--sources/pyside-tools/deploy_lib/pyside_icon.icnsbin0 -> 47064 bytes
-rw-r--r--sources/pyside-tools/deploy_lib/pyside_icon.icobin0 -> 48446 bytes
-rw-r--r--sources/pyside-tools/deploy_lib/pyside_icon.jpgbin0 -> 8157 bytes
-rw-r--r--sources/pyside-tools/deploy_lib/python_helper.py122
17 files changed, 2166 insertions, 0 deletions
diff --git a/sources/pyside-tools/deploy_lib/__init__.py b/sources/pyside-tools/deploy_lib/__init__.py
new file mode 100644
index 000000000..a40d0838b
--- /dev/null
+++ b/sources/pyside-tools/deploy_lib/__init__.py
@@ -0,0 +1,59 @@
+# Copyright (C) 2022 The Qt Company Ltd.
+# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR LGPL-3.0-only OR GPL-2.0-only OR GPL-3.0-only
+import sys
+from pathlib import Path
+from textwrap import dedent
+
+MAJOR_VERSION = 6
+
+if sys.platform == "win32":
+ IMAGE_FORMAT = ".ico"
+ EXE_FORMAT = ".exe"
+elif sys.platform == "darwin":
+ IMAGE_FORMAT = ".icns"
+ EXE_FORMAT = ".app"
+else:
+ IMAGE_FORMAT = ".jpg"
+ EXE_FORMAT = ".bin"
+
+DEFAULT_APP_ICON = str((Path(__file__).parent / f"pyside_icon{IMAGE_FORMAT}").resolve())
+IMPORT_WARNING_PYSIDE = (f"[DEPLOY] Found 'import PySide6' in file {0}"
+ ". Use 'from PySide6 import <module>' or pass the module"
+ " needed using --extra-modules command line argument")
+HELP_EXTRA_IGNORE_DIRS = dedent("""
+ Comma-separated directory names inside the project dir. These
+ directories will be skipped when searching for Python files
+ relevant to the project.
+
+ Example usage: --extra-ignore-dirs=doc,translations
+ """)
+
+HELP_EXTRA_MODULES = dedent("""
+ Comma-separated list of Qt modules to be added to the application,
+ in case they are not found automatically.
+
+ This occurs when you have 'import PySide6' in your code instead
+ 'from PySide6 import <module>'. The module name is specified
+ by either omitting the prefix of Qt or including it.
+
+ Example usage 1: --extra-modules=Network,Svg
+ Example usage 2: --extra-modules=QtNetwork,QtSvg
+ """)
+
+
+def get_all_pyside_modules():
+ """
+ Returns all the modules installed with PySide6
+ """
+ import PySide6
+ # They all start with `Qt` as the prefix. Removing this prefix and getting the actual
+ # module name
+ return [module[2:] for module in PySide6.__all__]
+
+
+from .commands import run_command, run_qmlimportscanner
+from .dependency_util import find_pyside_modules, find_permission_categories, QtDependencyReader
+from .nuitka_helper import Nuitka
+from .config import BaseConfig, Config, DesktopConfig
+from .python_helper import PythonExecutable
+from .deploy_util import cleanup, finalize, create_config_file, config_option_exists
diff --git a/sources/pyside-tools/deploy_lib/android/__init__.py b/sources/pyside-tools/deploy_lib/android/__init__.py
new file mode 100644
index 000000000..c3027762c
--- /dev/null
+++ b/sources/pyside-tools/deploy_lib/android/__init__.py
@@ -0,0 +1,16 @@
+# Copyright (C) 2023 The Qt Company Ltd.
+# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR LGPL-3.0-only OR GPL-2.0-only OR GPL-3.0-only
+
+# maps instruction set to Android platform names
+platform_map = {"aarch64": "arm64-v8a",
+ "armv7a": "armeabi-v7a",
+ "i686": "x86",
+ "x86_64": "x86_64",
+ "arm64-v8a": "arm64-v8a",
+ "armeabi-v7a": "armeabi-v7a",
+ "x86": "x86"}
+
+from .android_helper import (create_recipe, extract_and_copy_jar, get_wheel_android_arch,
+ AndroidData, get_llvm_readobj, find_lib_dependencies,
+ find_qtlibs_in_wheel)
+from .android_config import AndroidConfig
diff --git a/sources/pyside-tools/deploy_lib/android/android_config.py b/sources/pyside-tools/deploy_lib/android/android_config.py
new file mode 100644
index 000000000..ad818c2ff
--- /dev/null
+++ b/sources/pyside-tools/deploy_lib/android/android_config.py
@@ -0,0 +1,446 @@
+# Copyright (C) 2023 The Qt Company Ltd.
+# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR LGPL-3.0-only OR GPL-2.0-only OR GPL-3.0-only
+import re
+import tempfile
+import logging
+import zipfile
+import xml.etree.ElementTree as ET
+
+from typing import List
+from pathlib import Path
+from pkginfo import Wheel
+
+from . import (extract_and_copy_jar, get_wheel_android_arch, find_lib_dependencies,
+ get_llvm_readobj, find_qtlibs_in_wheel, platform_map, create_recipe)
+from .. import (Config, find_pyside_modules, get_all_pyside_modules, MAJOR_VERSION)
+
+ANDROID_NDK_VERSION = "26b"
+ANDROID_DEPLOY_CACHE = Path.home() / ".pyside6_android_deploy"
+
+
+class AndroidConfig(Config):
+ """
+ Wrapper class around pysidedeploy.spec file for pyside6-android-deploy
+ """
+ def __init__(self, config_file: Path, source_file: Path, python_exe: Path, dry_run: bool,
+ android_data, existing_config_file: bool = False,
+ extra_ignore_dirs: List[str] = None):
+ super().__init__(config_file=config_file, source_file=source_file, python_exe=python_exe,
+ dry_run=dry_run, existing_config_file=existing_config_file)
+
+ self.extra_ignore_dirs = extra_ignore_dirs
+
+ if android_data.wheel_pyside:
+ self.wheel_pyside = android_data.wheel_pyside
+ else:
+ wheel_pyside_temp = self.get_value("android", "wheel_pyside")
+ if not wheel_pyside_temp:
+ raise RuntimeError("[DEPLOY] Unable to find PySide6 Android wheel")
+ self.wheel_pyside = Path(wheel_pyside_temp).resolve()
+
+ if android_data.wheel_shiboken:
+ self.wheel_shiboken = android_data.wheel_shiboken
+ else:
+ wheel_shiboken_temp = self.get_value("android", "wheel_shiboken")
+ if not wheel_shiboken_temp:
+ raise RuntimeError("[DEPLOY] Unable to find shiboken6 Android wheel")
+ self.wheel_shiboken = Path(wheel_shiboken_temp).resolve()
+
+ self.ndk_path = None
+ if android_data.ndk_path:
+ # from cli
+ self.ndk_path = android_data.ndk_path
+ else:
+ # from config
+ ndk_path_temp = self.get_value("buildozer", "ndk_path")
+ if ndk_path_temp:
+ self.ndk_path = Path(ndk_path_temp)
+ else:
+ ndk_path_temp = (ANDROID_DEPLOY_CACHE / "android-ndk"
+ / f"android-ndk-r{ANDROID_NDK_VERSION}")
+ if ndk_path_temp.exists():
+ self.ndk_path = ndk_path_temp
+
+ if self.ndk_path:
+ print(f"Using Android NDK: {str(self.ndk_path)}")
+ else:
+ raise FileNotFoundError("[DEPLOY] Unable to find Android NDK. Please pass the NDK "
+ "path either from the CLI or from pysidedeploy.spec")
+
+ self.sdk_path = None
+ if android_data.sdk_path:
+ self.sdk_path = android_data.sdk_path
+ else:
+ sdk_path_temp = self.get_value("buildozer", "sdk_path")
+ if sdk_path_temp:
+ self.sdk_path = Path(sdk_path_temp)
+ else:
+ sdk_path_temp = ANDROID_DEPLOY_CACHE / "android-sdk"
+ if sdk_path_temp.exists():
+ self.sdk_path = sdk_path_temp
+ else:
+ logging.info("[DEPLOY] Use default SDK from buildozer")
+
+ if self.sdk_path:
+ print(f"Using Android SDK: {str(self.sdk_path)}")
+
+ recipe_dir_temp = self.get_value("buildozer", "recipe_dir")
+ self.recipe_dir = Path(recipe_dir_temp) if recipe_dir_temp else None
+
+ self._jars_dir = []
+ jars_dir_temp = self.get_value("buildozer", "jars_dir")
+ if jars_dir_temp and Path(jars_dir_temp).resolve().exists():
+ self.jars_dir = Path(jars_dir_temp).resolve()
+
+ self._arch = None
+ if self.get_value("buildozer", "arch"):
+ self.arch = self.get_value("buildozer", "arch")
+ else:
+ self._find_and_set_arch()
+
+ # maps to correct platform name incase the instruction set was specified
+ self._arch = platform_map[self.arch]
+
+ self._mode = self.get_value("buildozer", "mode")
+
+ self.qt_libs_path: zipfile.Path = find_qtlibs_in_wheel(wheel_pyside=self.wheel_pyside)
+ logging.info(f"[DEPLOY] Qt libs path inside wheel: {str(self.qt_libs_path)}")
+
+ if self.get_value("qt", "modules"):
+ self.modules = self.get_value("qt", "modules").split(",")
+ else:
+ self._find_and_set_pysidemodules()
+ self._find_and_set_qtquick_modules()
+ self.modules += self._find_dependent_qt_modules()
+ # remove duplicates
+ self.modules = list(set(self.modules))
+
+ # gets the xml dependency files from Qt installation path
+ self._dependency_files = []
+ self._find_and_set_dependency_files()
+
+ dependent_plugins = []
+ self._local_libs = []
+ if self.get_value("buildozer", "local_libs"):
+ self._local_libs = self.get_value("buildozer", "local_libs").split(",")
+ else:
+ # the local_libs can also store dependent plugins
+ local_libs, dependent_plugins = self._find_local_libs()
+ self.local_libs = list(set(local_libs))
+
+ self._qt_plugins = []
+ if self.get_value("android", "plugins"):
+ self._qt_plugins = self.get_value("android", "plugins").split(",")
+ elif dependent_plugins:
+ self._find_plugin_dependencies(dependent_plugins)
+ self.qt_plugins = list(set(dependent_plugins))
+
+ recipe_dir_temp = self.get_value("buildozer", "recipe_dir")
+ if recipe_dir_temp:
+ self.recipe_dir = Path(recipe_dir_temp)
+
+ @property
+ def qt_plugins(self):
+ return self._qt_plugins
+
+ @qt_plugins.setter
+ def qt_plugins(self, qt_plugins):
+ self._qt_plugins = qt_plugins
+ self.set_value("android", "plugins", ",".join(qt_plugins))
+
+ @property
+ def ndk_path(self):
+ return self._ndk_path
+
+ @ndk_path.setter
+ def ndk_path(self, ndk_path: Path):
+ self._ndk_path = ndk_path.resolve() if ndk_path else None
+ if self._ndk_path:
+ self.set_value("buildozer", "ndk_path", str(self._ndk_path))
+
+ @property
+ def sdk_path(self) -> Path:
+ return self._sdk_path
+
+ @sdk_path.setter
+ def sdk_path(self, sdk_path: Path):
+ self._sdk_path = sdk_path.resolve() if sdk_path else None
+ if self._sdk_path:
+ self.set_value("buildozer", "sdk_path", str(self._sdk_path))
+
+ @property
+ def arch(self):
+ return self._arch
+
+ @arch.setter
+ def arch(self, arch):
+ self._arch = arch
+ self.set_value("buildozer", "arch", arch)
+
+ @property
+ def mode(self):
+ return self._mode
+
+ @property
+ def modules(self):
+ return self._modules
+
+ @modules.setter
+ def modules(self, modules):
+ self._modules = modules
+ self.set_value("qt", "modules", ",".join(modules))
+
+ @property
+ def local_libs(self):
+ return self._local_libs
+
+ @local_libs.setter
+ def local_libs(self, local_libs):
+ self._local_libs = local_libs
+ self.set_value("buildozer", "local_libs", ",".join(local_libs))
+
+ @property
+ def recipe_dir(self):
+ return self._recipe_dir
+
+ @recipe_dir.setter
+ def recipe_dir(self, recipe_dir: Path):
+ self._recipe_dir = recipe_dir.resolve() if recipe_dir else None
+ if self._recipe_dir:
+ self.set_value("buildozer", "recipe_dir", str(self._recipe_dir))
+
+ def recipes_exist(self):
+ if not self._recipe_dir:
+ return False
+
+ pyside_recipe_dir = Path(self.recipe_dir) / "PySide6"
+ shiboken_recipe_dir = Path(self.recipe_dir) / "shiboken6"
+
+ return pyside_recipe_dir.is_dir() and shiboken_recipe_dir.is_dir()
+
+ @property
+ def jars_dir(self) -> Path:
+ return self._jars_dir
+
+ @jars_dir.setter
+ def jars_dir(self, jars_dir: Path):
+ self._jars_dir = jars_dir.resolve() if jars_dir else None
+ if self._jars_dir:
+ self.set_value("buildozer", "jars_dir", str(self._jars_dir))
+
+ @property
+ def wheel_pyside(self) -> Path:
+ return self._wheel_pyside
+
+ @wheel_pyside.setter
+ def wheel_pyside(self, wheel_pyside: Path):
+ self._wheel_pyside = wheel_pyside.resolve() if wheel_pyside else None
+ if self._wheel_pyside:
+ self.set_value("android", "wheel_pyside", str(self._wheel_pyside))
+
+ @property
+ def wheel_shiboken(self) -> Path:
+ return self._wheel_shiboken
+
+ @wheel_shiboken.setter
+ def wheel_shiboken(self, wheel_shiboken: Path):
+ self._wheel_shiboken = wheel_shiboken.resolve() if wheel_shiboken else None
+ if self._wheel_shiboken:
+ self.set_value("android", "wheel_shiboken", str(self._wheel_shiboken))
+
+ @property
+ def dependency_files(self):
+ return self._dependency_files
+
+ @dependency_files.setter
+ def dependency_files(self, dependency_files):
+ self._dependency_files = dependency_files
+
+ def _find_and_set_pysidemodules(self):
+ self.modules = find_pyside_modules(project_dir=self.project_dir,
+ extra_ignore_dirs=self.extra_ignore_dirs,
+ project_data=self.project_data)
+ logging.info("The following PySide modules were found from the python files of "
+ f"the project {self.modules}")
+
+ def find_and_set_jars_dir(self):
+ """Extract out and copy .jar files to {generated_files_path}
+ """
+ if not self.dry_run:
+ logging.info("[DEPLOY] Extract and copy jar files from PySide6 wheel to "
+ f"{self.generated_files_path}")
+ self.jars_dir = extract_and_copy_jar(wheel_path=self.wheel_pyside,
+ generated_files_path=self.generated_files_path)
+
+ def _find_and_set_arch(self):
+ """Find architecture from wheel name
+ """
+ self.arch = get_wheel_android_arch(wheel=self.wheel_pyside)
+ if not self.arch:
+ raise RuntimeError("[DEPLOY] PySide wheel corrupted. Wheel name should end with"
+ "platform name")
+
+ def _find_dependent_qt_modules(self):
+ """
+ Given pysidedeploy_config.modules, find all the other dependent Qt modules. This is
+ done by using llvm-readobj (readelf) to find the dependent libraries from the module
+ library.
+ """
+ dependent_modules = set()
+ all_dependencies = set()
+ lib_pattern = re.compile(f"libQt6(?P<mod_name>.*)_{self.arch}")
+
+ llvm_readobj = get_llvm_readobj(self.ndk_path)
+ if not llvm_readobj.exists():
+ raise FileNotFoundError(f"[DEPLOY] {llvm_readobj} does not exist."
+ "Finding Qt dependencies failed")
+
+ archive = zipfile.ZipFile(self.wheel_pyside)
+ lib_path_suffix = Path(str(self.qt_libs_path)).relative_to(self.wheel_pyside)
+
+ with tempfile.TemporaryDirectory() as tmpdir:
+ archive.extractall(tmpdir)
+ qt_libs_tmpdir = Path(tmpdir) / lib_path_suffix
+ # find the lib folder where Qt libraries are stored
+ for module_name in sorted(self.modules):
+ qt_module_path = qt_libs_tmpdir / f"libQt6{module_name}_{self.arch}.so"
+ if not qt_module_path.exists():
+ raise FileNotFoundError(f"[DEPLOY] libQt6{module_name}_{self.arch}.so not found"
+ " inside the wheel")
+ find_lib_dependencies(llvm_readobj=llvm_readobj, lib_path=qt_module_path,
+ dry_run=self.dry_run,
+ used_dependencies=all_dependencies)
+
+ for dependency in all_dependencies:
+ match = lib_pattern.search(dependency)
+ if match:
+ module = match.group("mod_name")
+ if module not in self.modules:
+ dependent_modules.add(module)
+
+ # check if the PySide6 binary for the Qt module actually exists
+ # eg: libQt6QmlModels.so exists and it includes QML types. Hence, it makes no
+ dependent_modules = [module for module in dependent_modules if module in
+ get_all_pyside_modules()]
+ dependent_modules_str = ",".join(dependent_modules)
+ logging.info("[DEPLOY] The following extra dependencies were found:"
+ f" {dependent_modules_str}")
+
+ return dependent_modules
+
+ def _find_and_set_dependency_files(self) -> List[zipfile.Path]:
+ """
+ Based on `modules`, returns the Qt6{module}_{arch}-android-dependencies.xml file, which
+ contains the various dependencies of the module, like permissions, plugins etc
+ """
+ needed_dependency_files = [(f"Qt{MAJOR_VERSION}{module}_{self.arch}"
+ "-android-dependencies.xml") for module in self.modules]
+
+ for dependency_file_name in needed_dependency_files:
+ dependency_file = self.qt_libs_path / dependency_file_name
+ if dependency_file.exists():
+ self._dependency_files.append(dependency_file)
+
+ logging.info("[DEPLOY] The following dependency files were found: "
+ f"{*self._dependency_files,}")
+
+ def _find_local_libs(self):
+ local_libs = set()
+ plugins = set()
+ lib_pattern = re.compile(f"lib(?P<lib_name>.*)_{self.arch}")
+ for dependency_file in self._dependency_files:
+ xml_content = dependency_file.read_text()
+ root = ET.fromstring(xml_content)
+ for local_lib in root.iter("lib"):
+
+ if 'file' not in local_lib.attrib:
+ if 'name' not in local_lib.attrib:
+ logging.warning("[DEPLOY] Invalid android dependency file"
+ f" {str(dependency_file)}")
+ continue
+
+ file = local_lib.attrib['file']
+ if file.endswith(".so"):
+ # file_name starts with lib and ends with the platform name
+ # eg: lib<lib_name>_x86_64.so
+ file_name = Path(file).stem
+
+ # we only need lib_name, because lib and arch gets re-added by
+ # python-for-android
+ match = lib_pattern.search(file_name)
+ if match:
+ lib_name = match.group("lib_name")
+ local_libs.add(lib_name)
+ if lib_name.startswith("plugins"):
+ plugin_name = lib_name.split('plugins_', 1)[1]
+ plugins.add(plugin_name)
+
+ return list(local_libs), list(plugins)
+
+ def _find_plugin_dependencies(self, dependent_plugins: List[str]):
+ # The `bundled` element in the dependency xml files points to the folder where
+ # additional dependencies for the application exists. Inspecting the depenency files
+ # in android, this always points to the specific Qt plugin dependency folder.
+ # eg: for application using Qt Multimedia, this looks like:
+ # <bundled file="./plugins/multimedia" />
+ # The code recusively checks all these dependent folders and adds the necessary plugins
+ # as dependencies
+ lib_pattern = re.compile(f"libplugins_(?P<plugin_name>.*)_{self.arch}.so")
+ for dependency_file in self._dependency_files:
+ xml_content = dependency_file.read_text()
+ root = ET.fromstring(xml_content)
+ for bundled_element in root.iter("bundled"):
+ # the attribute 'file' can be misleading, but it always points to the plugin
+ # folder on inspecting the dependency files
+ if 'file' not in bundled_element.attrib:
+ logging.warning("[DEPLOY] Invalid Android dependency file"
+ f" {str(dependency_file)}")
+ continue
+
+ # from "./plugins/multimedia" to absolute path in wheel
+ plugin_module_folder = bundled_element.attrib['file']
+ # they all should start with `./plugins`
+ if plugin_module_folder.startswith("./plugins"):
+ plugin_module_folder = plugin_module_folder.partition("./plugins/")[2]
+ else:
+ continue
+
+ absolute_plugin_module_folder = (self.qt_libs_path.parent / "plugins"
+ / plugin_module_folder)
+
+ if not absolute_plugin_module_folder.is_dir():
+ logging.warning(f"[DEPLOY] Qt plugin folder '{plugin_module_folder}' does not"
+ " exist or is not a directory for this Android platform")
+ continue
+
+ for plugin in absolute_plugin_module_folder.iterdir():
+ plugin_name = plugin.name
+ if plugin_name.endswith(".so") and plugin_name.startswith("libplugins"):
+ # we only need part of plugin_name, because `lib` prefix and `arch` suffix
+ # gets re-added by python-for-android
+ match = lib_pattern.search(plugin_name)
+ if match:
+ plugin_infix_name = match.group("plugin_name")
+ if plugin_infix_name not in dependent_plugins:
+ dependent_plugins.append(plugin_infix_name)
+
+ def verify_and_set_recipe_dir(self):
+ # create recipes
+ # https://python-for-android.readthedocs.io/en/latest/recipes/
+ # These recipes are manually added through buildozer.spec file to be used by
+ # python_for_android while building the distribution
+
+ if not self.recipes_exist() and not self.dry_run:
+ logging.info("[DEPLOY] Creating p4a recipes for PySide6 and shiboken6")
+ version = Wheel(self.wheel_pyside).version
+ create_recipe(version=version, component=f"PySide{MAJOR_VERSION}",
+ wheel_path=self.wheel_pyside,
+ generated_files_path=self.generated_files_path,
+ qt_modules=self.modules,
+ local_libs=self.local_libs,
+ plugins=self.qt_plugins)
+ create_recipe(version=version, component=f"shiboken{MAJOR_VERSION}",
+ wheel_path=self.wheel_shiboken,
+ generated_files_path=self.generated_files_path)
+ self.recipe_dir = ((self.generated_files_path
+ / "recipes").resolve())
diff --git a/sources/pyside-tools/deploy_lib/android/android_helper.py b/sources/pyside-tools/deploy_lib/android/android_helper.py
new file mode 100644
index 000000000..7d2f5d575
--- /dev/null
+++ b/sources/pyside-tools/deploy_lib/android/android_helper.py
@@ -0,0 +1,151 @@
+# Copyright (C) 2023 The Qt Company Ltd.
+# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR LGPL-3.0-only OR GPL-2.0-only OR GPL-3.0-only
+
+import logging
+import zipfile
+from dataclasses import dataclass
+from pathlib import Path
+from typing import List, Set
+from zipfile import ZipFile
+
+from jinja2 import Environment, FileSystemLoader
+
+from .. import run_command
+
+
+@dataclass
+class AndroidData:
+ """
+ Dataclass to store all the Android data obtained through cli
+ """
+ wheel_pyside: Path
+ wheel_shiboken: Path
+ ndk_path: Path
+ sdk_path: Path
+
+
+def create_recipe(version: str, component: str, wheel_path: str, generated_files_path: Path,
+ qt_modules: List[str] = None, local_libs: List[str] = None,
+ plugins: List[str] = None):
+ '''
+ Create python_for_android recipe for PySide6 and shiboken6
+ '''
+ qt_plugins = []
+ if plugins:
+ # split plugins based on category
+ for plugin in plugins:
+ plugin_category, plugin_name = plugin.split('_', 1)
+ qt_plugins.append((plugin_category, plugin_name))
+
+ qt_local_libs = []
+ if local_libs:
+ qt_local_libs = [local_lib for local_lib in local_libs if local_lib.startswith("Qt6")]
+
+ rcp_tmpl_path = Path(__file__).parent / "recipes" / f"{component}"
+ environment = Environment(loader=FileSystemLoader(rcp_tmpl_path))
+ template = environment.get_template("__init__.tmpl.py")
+ content = template.render(
+ version=version,
+ wheel_path=wheel_path,
+ qt_modules=qt_modules,
+ qt_local_libs=qt_local_libs,
+ qt_plugins=qt_plugins
+ )
+
+ recipe_path = generated_files_path / "recipes" / f"{component}"
+ recipe_path.mkdir(parents=True, exist_ok=True)
+ logging.info(f"[DEPLOY] Writing {component} recipe into {str(recipe_path)}")
+ with open(recipe_path / "__init__.py", mode="w", encoding="utf-8") as recipe:
+ recipe.write(content)
+
+
+def extract_and_copy_jar(wheel_path: Path, generated_files_path: Path) -> str:
+ '''
+ extracts the PySide6 wheel and copies the 'jar' folder to 'generated_files_path'.
+ These .jar files are added to the buildozer.spec file to be used later by buildozer
+ '''
+ jar_path = generated_files_path / "jar"
+ jar_path.mkdir(parents=True, exist_ok=True)
+ archive = ZipFile(wheel_path)
+ jar_files = [file for file in archive.namelist() if file.startswith("PySide6/jar")]
+ for file in jar_files:
+ archive.extract(file, jar_path)
+ return (jar_path / "PySide6" / "jar").resolve() if jar_files else None
+
+
+def get_wheel_android_arch(wheel: Path):
+ '''
+ Get android architecture from wheel
+ '''
+ supported_archs = ["aarch64", "armv7a", "i686", "x86_64"]
+ for arch in supported_archs:
+ if arch in wheel.stem:
+ return arch
+
+ return None
+
+
+def get_llvm_readobj(ndk_path: Path) -> Path:
+ '''
+ Return the path to llvm_readobj from the Android Ndk
+ '''
+ # TODO: Requires change if Windows platform supports Android Deployment or if we
+ # support host other than linux-x86_64
+ return (ndk_path / "toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-readobj")
+
+
+def find_lib_dependencies(llvm_readobj: Path, lib_path: Path, used_dependencies: Set[str] = None,
+ dry_run: bool = False):
+ """
+ Find all the Qt dependencies of a library using llvm_readobj
+ """
+ if lib_path.name in used_dependencies:
+ return
+
+ used_dependencies.add(lib_path.name)
+
+ command = [str(llvm_readobj), "--needed-libs", str(lib_path)]
+
+ # even if dry_run is given, we need to run the actual command to see all the dependencies
+ # for which llvm-readelf is run.
+ if dry_run:
+ _, output = run_command(command=command, dry_run=dry_run, fetch_output=True)
+ _, output = run_command(command=command, dry_run=False, fetch_output=True)
+
+ dependencies = set()
+ neededlibraries_found = False
+ for line in output.splitlines():
+ line = line.decode("utf-8").lstrip()
+ if line.startswith("NeededLibraries") and not neededlibraries_found:
+ neededlibraries_found = True
+ if neededlibraries_found and line.startswith("libQt"):
+ dependencies.add(line)
+ used_dependencies.add(line)
+ dependent_lib_path = lib_path.parent / line
+ find_lib_dependencies(llvm_readobj, dependent_lib_path, used_dependencies, dry_run)
+
+ if dependencies:
+ logging.info(f"[DEPLOY] Following dependencies found for {lib_path.stem}: {dependencies}")
+ else:
+ logging.info(f"[DEPLOY] No Qt dependencies found for {lib_path.stem}")
+
+
+def find_qtlibs_in_wheel(wheel_pyside: Path):
+ """
+ Find the path to Qt/lib folder inside the wheel.
+ """
+ archive = ZipFile(wheel_pyside)
+ qt_libs_path = wheel_pyside / "PySide6/Qt/lib"
+ qt_libs_path = zipfile.Path(archive, at=qt_libs_path)
+ if not qt_libs_path.exists():
+ for file in archive.namelist():
+ # the dependency files are inside the libs folder
+ if file.endswith("android-dependencies.xml"):
+ qt_libs_path = zipfile.Path(archive, at=file).parent
+ # all dependency files are in the same path
+ break
+
+ if not qt_libs_path:
+ raise FileNotFoundError("[DEPLOY] Unable to find Qt libs folder inside the wheel")
+
+ return qt_libs_path
diff --git a/sources/pyside-tools/deploy_lib/android/buildozer.py b/sources/pyside-tools/deploy_lib/android/buildozer.py
new file mode 100644
index 000000000..0c314c356
--- /dev/null
+++ b/sources/pyside-tools/deploy_lib/android/buildozer.py
@@ -0,0 +1,147 @@
+# Copyright (C) 2023 The Qt Company Ltd.
+# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR LGPL-3.0-only OR GPL-2.0-only OR GPL-3.0-only
+
+import sys
+import logging
+import xml.etree.ElementTree as ET
+import zipfile
+from pathlib import Path
+from typing import List
+
+from . import AndroidConfig
+from .. import BaseConfig, run_command
+
+
+class BuildozerConfig(BaseConfig):
+ def __init__(self, buildozer_spec_file: Path, pysidedeploy_config: AndroidConfig):
+ super().__init__(buildozer_spec_file, comment_prefixes="#")
+ self.set_value("app", "title", pysidedeploy_config.title)
+ self.set_value("app", "package.name", pysidedeploy_config.title)
+ self.set_value("app", "package.domain",
+ f"org.{pysidedeploy_config.title}")
+
+ include_exts = self.get_value("app", "source.include_exts")
+ include_exts = f"{include_exts},qml,js"
+ self.set_value("app", "source.include_exts", include_exts, raise_warning=False)
+
+ self.set_value("app", "requirements", "python3,shiboken6,PySide6")
+
+ # android platform specific
+ if pysidedeploy_config.ndk_path:
+ self.set_value("app", "android.ndk_path", str(pysidedeploy_config.ndk_path))
+
+ if pysidedeploy_config.sdk_path:
+ self.set_value("app", "android.sdk_path", str(pysidedeploy_config.sdk_path))
+
+ self.set_value("app", "android.archs", pysidedeploy_config.arch)
+
+ # p4a changes
+ self.set_value("app", "p4a.bootstrap", "qt")
+ self.set_value('app', "p4a.local_recipes", str(pysidedeploy_config.recipe_dir))
+
+ # add p4a branch
+ # by default the master branch is used
+ # https://github.com/kivy/python-for-android/commit/b92522fab879dbfc0028966ca3c59ef46ab7767d
+ # has not been merged to master yet. So, we use the develop branch for now
+ # TODO: remove this once the above commit is merged to master
+ self.set_value("app", "p4a.branch", "develop")
+
+ # add permissions
+ permissions = self.__find_permissions(pysidedeploy_config.dependency_files)
+ permissions = ", ".join(permissions)
+ self.set_value("app", "android.permissions", permissions)
+
+ # add jars and initClasses for the jars
+ jars, init_classes = self.__find_jars(pysidedeploy_config.dependency_files,
+ pysidedeploy_config.jars_dir)
+ self.set_value("app", "android.add_jars", ",".join(jars))
+
+ # extra arguments specific to Qt
+ modules = ",".join(pysidedeploy_config.modules)
+ local_libs = ",".join(pysidedeploy_config.local_libs)
+ init_classes = ",".join(init_classes)
+ extra_args = (f"--qt-libs={modules} --load-local-libs={local_libs}"
+ f" --init-classes={init_classes}")
+ self.set_value("app", "p4a.extra_args", extra_args)
+
+ # TODO: does not work atm. Seems like a bug with buildozer
+ # change buildozer build_dir
+ # self.set_value("buildozer", "build_dir", str(build_dir.relative_to(Path.cwd())))
+
+ # change final apk/aab path
+ self.set_value("buildozer", "bin_dir", str(pysidedeploy_config.exe_dir.resolve()))
+
+ # set application icon
+ self.set_value("app", "icon.filename", pysidedeploy_config.icon)
+
+ self.update_config()
+
+ def __find_permissions(self, dependency_files: List[zipfile.Path]):
+ permissions = set()
+ for dependency_file in dependency_files:
+ xml_content = dependency_file.read_text()
+ root = ET.fromstring(xml_content)
+ for permission in root.iter("permission"):
+ permissions.add(permission.attrib['name'])
+ return permissions
+
+ def __find_jars(self, dependency_files: List[zipfile.Path], jars_dir: Path):
+ jars, init_classes = set(), set()
+ for dependency_file in dependency_files:
+ xml_content = dependency_file.read_text()
+ root = ET.fromstring(xml_content)
+ for jar in root.iter("jar"):
+ jar_file = jar.attrib['file']
+ if jar_file.startswith("jar/"):
+ jar_file_name = jar_file[4:]
+ if (jars_dir / jar_file_name).exists():
+ jars.add(str(jars_dir / jar_file_name))
+ else:
+ logging.warning(f"[DEPLOY] Unable to include {jar_file}. "
+ f"{jar_file} does not exist in {jars_dir}")
+ continue
+ else:
+ logging.warning(f"[DEPLOY] Unable to include {jar_file}. "
+ "All jar file paths should begin with 'jar/'")
+ continue
+
+ jar_init_class = jar.attrib.get('initClass')
+ if jar_init_class:
+ init_classes.add(jar_init_class)
+
+ # add the jar with all the activity and service java files
+ # this is created from Qt for Python instead of Qt
+ # The initClasses for this are already taken care of by python-for-android
+ android_bindings_jar = jars_dir / "Qt6AndroidBindings.jar"
+ if android_bindings_jar.exists():
+ jars.add(str(android_bindings_jar))
+ else:
+ raise FileNotFoundError(f"{android_bindings_jar} not found in wheel")
+
+ return jars, init_classes
+
+
+class Buildozer:
+ dry_run = False
+
+ @staticmethod
+ def initialize(pysidedeploy_config: AndroidConfig):
+ project_dir = Path(pysidedeploy_config.project_dir)
+ buildozer_spec = project_dir / "buildozer.spec"
+ if buildozer_spec.exists():
+ logging.warning(f"[DEPLOY] buildozer.spec already present in {str(project_dir)}."
+ "Using it")
+ return
+
+ # creates buildozer.spec config file
+ command = [sys.executable, "-m", "buildozer", "init"]
+ run_command(command=command, dry_run=Buildozer.dry_run)
+ if not Buildozer.dry_run:
+ if not buildozer_spec.exists():
+ raise RuntimeError(f"buildozer.spec not found in {Path.cwd()}")
+ BuildozerConfig(buildozer_spec, pysidedeploy_config)
+
+ @staticmethod
+ def create_executable(mode: str):
+ command = [sys.executable, "-m", "buildozer", "android", mode]
+ run_command(command=command, dry_run=Buildozer.dry_run)
diff --git a/sources/pyside-tools/deploy_lib/android/recipes/PySide6/__init__.tmpl.py b/sources/pyside-tools/deploy_lib/android/recipes/PySide6/__init__.tmpl.py
new file mode 100644
index 000000000..8a8615798
--- /dev/null
+++ b/sources/pyside-tools/deploy_lib/android/recipes/PySide6/__init__.tmpl.py
@@ -0,0 +1,64 @@
+# Copyright (C) 2023 The Qt Company Ltd.
+# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR LGPL-3.0-only OR GPL-2.0-only OR GPL-3.0-only
+
+import shutil
+import zipfile
+from pathlib import Path
+
+from pythonforandroid.logger import info
+from pythonforandroid.recipe import PythonRecipe
+
+
+class PySideRecipe(PythonRecipe):
+ version = '{{ version }}'
+ wheel_path = '{{ wheel_path }}'
+ depends = ["shiboken6"]
+ call_hostpython_via_targetpython = False
+ install_in_hostpython = False
+
+ def build_arch(self, arch):
+ """Unzip the wheel and copy into site-packages of target"""
+
+ info("Copying libc++_shared.so from SDK to be loaded on startup")
+ libcpp_path = f"{self.ctx.ndk.sysroot_lib_dir}/{arch.command_prefix}/libc++_shared.so"
+ shutil.copyfile(libcpp_path, Path(self.ctx.get_libs_dir(arch.arch)) / "libc++_shared.so")
+
+ info(f"Installing {self.name} into site-packages")
+ with zipfile.ZipFile(self.wheel_path, "r") as zip_ref:
+ info("Unzip wheels and copy into {}".format(self.ctx.get_python_install_dir(arch.arch)))
+ zip_ref.extractall(self.ctx.get_python_install_dir(arch.arch))
+
+ lib_dir = Path(f"{self.ctx.get_python_install_dir(arch.arch)}/PySide6/Qt/lib")
+
+ info("Copying Qt libraries to be loaded on startup")
+ shutil.copytree(lib_dir, self.ctx.get_libs_dir(arch.arch), dirs_exist_ok=True)
+ shutil.copyfile(lib_dir.parent.parent / "libpyside6.abi3.so",
+ Path(self.ctx.get_libs_dir(arch.arch)) / "libpyside6.abi3.so")
+
+ {% for module in qt_modules %} # noqa: E999
+ shutil.copyfile(lib_dir.parent.parent / f"Qt{{ module }}.abi3.so",
+ Path(self.ctx.get_libs_dir(arch.arch)) / "Qt{{ module }}.abi3.so")
+ {% if module == "Qml" -%} # noqa: E999
+ shutil.copyfile(lib_dir.parent.parent / "libpyside6qml.abi3.so",
+ Path(self.ctx.get_libs_dir(arch.arch)) / "libpyside6qml.abi3.so")
+ {% endif %} # noqa: E999
+ {% endfor %} # noqa: E999
+
+ {% for lib in qt_local_libs %} # noqa: E999
+ lib_path = lib_dir / f"lib{{ lib }}_{arch.arch}.so"
+ if lib_path.exists():
+ shutil.copyfile(lib_path,
+ Path(self.ctx.get_libs_dir(arch.arch)) / f"lib{{ lib }}_{arch.arch}.so")
+ {% endfor %} # noqa: E999
+
+ {% for plugin_category,plugin_name in qt_plugins %} # noqa: E999
+ plugin_path = (lib_dir.parent / "plugins" / "{{ plugin_category }}" /
+ f"libplugins_{{ plugin_category }}_{{ plugin_name }}_{arch.arch}.so")
+ if plugin_path.exists():
+ shutil.copyfile(plugin_path,
+ (Path(self.ctx.get_libs_dir(arch.arch)) /
+ f"libplugins_{{ plugin_category }}_{{ plugin_name }}_{arch.arch}.so"))
+ {% endfor %} # noqa: E999
+
+
+recipe = PySideRecipe()
diff --git a/sources/pyside-tools/deploy_lib/android/recipes/shiboken6/__init__.tmpl.py b/sources/pyside-tools/deploy_lib/android/recipes/shiboken6/__init__.tmpl.py
new file mode 100644
index 000000000..d6ab037bf
--- /dev/null
+++ b/sources/pyside-tools/deploy_lib/android/recipes/shiboken6/__init__.tmpl.py
@@ -0,0 +1,31 @@
+# Copyright (C) 2023 The Qt Company Ltd.
+# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR LGPL-3.0-only OR GPL-2.0-only OR GPL-3.0-only
+
+import shutil
+import zipfile
+from pathlib import Path
+
+from pythonforandroid.logger import info
+from pythonforandroid.recipe import PythonRecipe
+
+
+class ShibokenRecipe(PythonRecipe):
+ version = '{{ version }}'
+ wheel_path = '{{ wheel_path }}'
+
+ call_hostpython_via_targetpython = False
+ install_in_hostpython = False
+
+ def build_arch(self, arch):
+ ''' Unzip the wheel and copy into site-packages of target'''
+ info('Installing {} into site-packages'.format(self.name))
+ with zipfile.ZipFile(self.wheel_path, 'r') as zip_ref:
+ info('Unzip wheels and copy into {}'.format(self.ctx.get_python_install_dir(arch.arch)))
+ zip_ref.extractall(self.ctx.get_python_install_dir(arch.arch))
+
+ lib_dir = Path(f"{self.ctx.get_python_install_dir(arch.arch)}/shiboken6")
+ shutil.copyfile(lib_dir / "libshiboken6.abi3.so",
+ Path(self.ctx.get_libs_dir(arch.arch)) / "libshiboken6.abi3.so")
+
+
+recipe = ShibokenRecipe()
diff --git a/sources/pyside-tools/deploy_lib/commands.py b/sources/pyside-tools/deploy_lib/commands.py
new file mode 100644
index 000000000..3a7e2a2e2
--- /dev/null
+++ b/sources/pyside-tools/deploy_lib/commands.py
@@ -0,0 +1,60 @@
+# Copyright (C) 2022 The Qt Company Ltd.
+# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR LGPL-3.0-only OR GPL-2.0-only OR GPL-3.0-only
+
+import json
+import subprocess
+import sys
+from pathlib import Path
+from typing import List
+
+"""
+All utility functions for deployment
+"""
+
+
+def run_command(command, dry_run: bool, fetch_output: bool = False):
+ command_str = " ".join([str(cmd) for cmd in command])
+ output = None
+ is_windows = (sys.platform == "win32")
+ try:
+ if not dry_run:
+ if fetch_output:
+ output = subprocess.check_output(command, shell=is_windows)
+ else:
+ subprocess.check_call(command, shell=is_windows)
+ else:
+ print(command_str + "\n")
+ except FileNotFoundError as error:
+ raise FileNotFoundError(f"[DEPLOY] {error.filename} not found")
+ except subprocess.CalledProcessError as error:
+ raise RuntimeError(
+ f"[DEPLOY] Command {command_str} failed with error {error} and return_code"
+ f"{error.returncode}"
+ )
+ except Exception as error:
+ raise RuntimeError(f"[DEPLOY] Command {command_str} failed with error {error}")
+
+ return command_str, output
+
+
+def run_qmlimportscanner(qml_files: List[Path], dry_run: bool):
+ """
+ Runs pyside6-qmlimportscanner to find all the imported qml modules
+ """
+ if not qml_files:
+ return []
+
+ qml_modules = []
+ cmd = ["pyside6-qmlimportscanner", "-qmlFiles"]
+ cmd.extend([str(qml_file) for qml_file in qml_files])
+
+ if dry_run:
+ run_command(command=cmd, dry_run=True)
+
+ # we need to run qmlimportscanner during dry_run as well to complete the
+ # command being run by nuitka
+ _, json_string = run_command(command=cmd, dry_run=False, fetch_output=True)
+ json_string = json_string.decode("utf-8")
+ json_array = json.loads(json_string)
+ qml_modules = [item['name'] for item in json_array if item['type'] == "module"]
+ return qml_modules
diff --git a/sources/pyside-tools/deploy_lib/config.py b/sources/pyside-tools/deploy_lib/config.py
new file mode 100644
index 000000000..d59dd92ad
--- /dev/null
+++ b/sources/pyside-tools/deploy_lib/config.py
@@ -0,0 +1,459 @@
+# Copyright (C) 2022 The Qt Company Ltd.
+# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR LGPL-3.0-only OR GPL-2.0-only OR GPL-3.0-only
+
+import sys
+import configparser
+import logging
+import warnings
+from configparser import ConfigParser
+from typing import List
+from pathlib import Path
+
+from project import ProjectData
+from . import (DEFAULT_APP_ICON, find_pyside_modules, find_permission_categories,
+ QtDependencyReader, run_qmlimportscanner)
+
+# Some QML plugins like QtCore are excluded from this list as they don't contribute much to
+# executable size. Excluding them saves the extra processing of checking for them in files
+EXCLUDED_QML_PLUGINS = {"QtQuick", "QtQuick3D", "QtCharts", "QtWebEngine", "QtTest", "QtSensors"}
+
+PERMISSION_MAP = {"Bluetooth": "NSBluetoothAlwaysUsageDescription:BluetoothAccess",
+ "Camera": "NSCameraUsageDescription:CameraAccess",
+ "Microphone": "NSMicrophoneUsageDescription:MicrophoneAccess",
+ "Contacts": "NSContactsUsageDescription:ContactsAccess",
+ "Calendar": "NSCalendarsUsageDescription:CalendarAccess",
+ # for iOS NSLocationWhenInUseUsageDescription and
+ # NSLocationAlwaysAndWhenInUseUsageDescription are also required.
+ "Location": "NSLocationUsageDescription:LocationAccess",
+ }
+
+
+class BaseConfig:
+ """Wrapper class around any .spec file with function to read and set values for the .spec file
+ """
+ def __init__(self, config_file: Path, comment_prefixes: str = "/",
+ existing_config_file: bool = False) -> None:
+ self.config_file = config_file
+ self.existing_config_file = existing_config_file
+ self.parser = ConfigParser(comment_prefixes=comment_prefixes, strict=False,
+ allow_no_value=True)
+ self.parser.read(self.config_file)
+
+ def update_config(self):
+ logging.info(f"[DEPLOY] Creating {self.config_file}")
+ with open(self.config_file, "w+") as config_file:
+ self.parser.write(config_file, space_around_delimiters=True)
+
+ def set_value(self, section: str, key: str, new_value: str, raise_warning: bool = True):
+ try:
+ current_value = self.get_value(section, key, ignore_fail=True)
+ if current_value != new_value:
+ self.parser.set(section, key, new_value)
+ except configparser.NoOptionError:
+ if raise_warning:
+ logging.warning(f"[DEPLOY] Key {key} does not exist")
+ except configparser.NoSectionError:
+ if raise_warning:
+ logging.warning(f"[DEPLOY] Section {section} does not exist")
+
+ def get_value(self, section: str, key: str, ignore_fail: bool = False):
+ try:
+ return self.parser.get(section, key)
+ except configparser.NoOptionError:
+ if not ignore_fail:
+ logging.warning(f"[DEPLOY] Key {key} does not exist")
+ except configparser.NoSectionError:
+ if not ignore_fail:
+ logging.warning(f"[DEPLOY] Section {section} does not exist")
+
+
+class Config(BaseConfig):
+ """
+ Wrapper class around pysidedeploy.spec file, whose options are used to control the executable
+ creation
+ """
+
+ def __init__(self, config_file: Path, source_file: Path, python_exe: Path, dry_run: bool,
+ existing_config_file: bool = False, extra_ignore_dirs: List[str] = None):
+ super().__init__(config_file=config_file, existing_config_file=existing_config_file)
+
+ self.extra_ignore_dirs = extra_ignore_dirs
+ self._dry_run = dry_run
+ self.qml_modules = set()
+ # set source_file
+ self.source_file = Path(
+ self.set_or_fetch(config_property_val=source_file, config_property_key="input_file")
+ ).resolve()
+
+ # set python path
+ self.python_path = Path(
+ self.set_or_fetch(
+ config_property_val=python_exe,
+ config_property_key="python_path",
+ config_property_group="python",
+ )
+ )
+
+ self.title = self.get_value("app", "title")
+
+ # set application icon
+ config_icon = self.get_value("app", "icon")
+ if config_icon:
+ self.icon = str(Path(config_icon).resolve())
+ else:
+ self.icon = DEFAULT_APP_ICON
+
+ self.project_dir = None
+ if self.get_value("app", "project_dir"):
+ self.project_dir = Path(self.get_value("app", "project_dir")).absolute()
+ else:
+ self._find_and_set_project_dir()
+
+ self.exe_dir = None
+ if self.get_value("app", "exec_directory"):
+ self.exe_dir = Path(self.get_value("app", "exec_directory")).absolute()
+ else:
+ self._find_and_set_exe_dir()
+
+ self.project_data: ProjectData = None
+ if self.get_value("app", "project_file"):
+ project_file = Path(self.get_value("app", "project_file")).absolute()
+ self.project_data = ProjectData(project_file=project_file)
+ else:
+ self._find_and_set_project_file()
+
+ self.qml_files = []
+ config_qml_files = self.get_value("qt", "qml_files")
+ if config_qml_files and self.project_dir and self.existing_config_file:
+ self.qml_files = [Path(self.project_dir) / file for file in config_qml_files.split(",")]
+ else:
+ self._find_and_set_qml_files()
+
+ self.excluded_qml_plugins = []
+ if self.get_value("qt", "excluded_qml_plugins") and self.existing_config_file:
+ self.excluded_qml_plugins = self.get_value("qt", "excluded_qml_plugins").split(",")
+ else:
+ self._find_and_set_excluded_qml_plugins()
+
+ self._generated_files_path = self.project_dir / "deployment"
+
+ self.modules = []
+
+ def set_or_fetch(self, config_property_val, config_property_key, config_property_group="app"):
+ """
+ Write to config_file if 'config_property_key' is known without config_file
+ Fetch and return from config_file if 'config_property_key' is unknown, but
+ config_file exists
+ Otherwise, raise an exception
+ """
+ if config_property_val:
+ self.set_value(config_property_group, config_property_key, str(config_property_val))
+ return config_property_val
+ elif self.get_value(config_property_group, config_property_key):
+ return self.get_value(config_property_group, config_property_key)
+ else:
+ raise RuntimeError(
+ f"[DEPLOY] No {config_property_key} specified in config file or as cli option"
+ )
+
+ @property
+ def dry_run(self):
+ return self._dry_run
+
+ @property
+ def generated_files_path(self):
+ return self._generated_files_path
+
+ @property
+ def qml_files(self):
+ return self._qml_files
+
+ @qml_files.setter
+ def qml_files(self, qml_files):
+ self._qml_files = qml_files
+
+ @property
+ def project_dir(self):
+ return self._project_dir
+
+ @project_dir.setter
+ def project_dir(self, project_dir):
+ self._project_dir = project_dir
+
+ @property
+ def title(self):
+ return self._title
+
+ @title.setter
+ def title(self, title):
+ self._title = title
+ self.set_value("app", "title", title)
+
+ @property
+ def icon(self):
+ return self._icon
+
+ @icon.setter
+ def icon(self, icon):
+ self._icon = icon
+ self.set_value("app", "icon", icon)
+
+ @property
+ def source_file(self):
+ return self._source_file
+
+ @source_file.setter
+ def source_file(self, source_file: Path):
+ self._source_file = source_file
+
+ @property
+ def python_path(self):
+ return self._python_path
+
+ @python_path.setter
+ def python_path(self, python_path: Path):
+ self._python_path = python_path
+
+ @property
+ def extra_args(self):
+ return self.get_value("nuitka", "extra_args")
+
+ @extra_args.setter
+ def extra_args(self, extra_args):
+ self.set_value("nuitka", "extra_args", extra_args)
+
+ @property
+ def excluded_qml_plugins(self):
+ return self._excluded_qml_plugins
+
+ @excluded_qml_plugins.setter
+ def excluded_qml_plugins(self, excluded_qml_plugins):
+ self._excluded_qml_plugins = excluded_qml_plugins
+
+ @property
+ def exe_dir(self):
+ return self._exe_dir
+
+ @exe_dir.setter
+ def exe_dir(self, exe_dir: Path):
+ self._exe_dir = exe_dir
+
+ @property
+ def modules(self):
+ return self._modules
+
+ @modules.setter
+ def modules(self, modules):
+ self._modules = modules
+ self.set_value("qt", "modules", ",".join(modules))
+
+ def _find_and_set_qml_files(self):
+ """Fetches all the qml_files in the folder and sets them if the
+ field qml_files is empty in the config_dir"""
+
+ if self.project_data:
+ qml_files = self.project_data.qml_files
+ for sub_project_file in self.project_data.sub_projects_files:
+ qml_files.extend(ProjectData(project_file=sub_project_file).qml_files)
+ self.qml_files = qml_files
+ else:
+ qml_files_temp = None
+ if self.source_file and self.python_path:
+ if not self.qml_files:
+ qml_files_temp = list(self.source_file.parent.glob("**/*.qml"))
+
+ # add all QML files, excluding the ones shipped with installed PySide6
+ # The QML files shipped with PySide6 gets added if venv is used,
+ # because of recursive glob
+ if self.python_path.parent.parent == self.source_file.parent:
+ # python venv path is inside the main source dir
+ qml_files_temp = list(
+ set(qml_files_temp) - set(self.python_path.parent.parent.rglob("*.qml"))
+ )
+
+ if len(qml_files_temp) > 500:
+ if "site-packages" in str(qml_files_temp[-1]):
+ raise RuntimeError(
+ "You are including a lot of QML files from a local virtual env."
+ " This can lead to errors in deployment."
+ )
+ else:
+ warnings.warn(
+ "You seem to include a lot of QML files. This can lead to errors in "
+ "deployment."
+ )
+
+ if qml_files_temp:
+ extra_qml_files = [Path(file) for file in qml_files_temp]
+ self.qml_files.extend(extra_qml_files)
+ if self.qml_files:
+ self.set_value(
+ "qt",
+ "qml_files",
+ ",".join([str(file.absolute().relative_to(self.project_dir))
+ for file in self.qml_files]),
+ )
+ logging.info("[DEPLOY] QML files identified and set in config_file")
+
+ def _find_and_set_project_dir(self):
+ # there is no other way to find the project_dir than assume it is the parent directory
+ # of source_file
+ self.project_dir = self.source_file.parent
+
+ # update input_file path
+ self.set_value("app", "input_file", str(self.source_file.relative_to(self.project_dir)))
+
+ if self.project_dir != Path.cwd():
+ self.set_value("app", "project_dir", str(self.project_dir))
+ else:
+ self.set_value("app", "project_dir", str(self.project_dir.relative_to(Path.cwd())))
+
+ def _find_and_set_project_file(self):
+ if self.project_dir:
+ files = list(self.project_dir.glob("*.pyproject"))
+ else:
+ logging.exception("[DEPLOY] Project directory not set in config file")
+ raise
+
+ if not files:
+ logging.info("[DEPLOY] No .pyproject file found. Project file not set")
+ elif len(files) > 1:
+ logging.warning("DEPLOY: More that one .pyproject files found. Project file not set")
+ raise
+ else:
+ self.project_data = ProjectData(files[0])
+ self.set_value("app", "project_file", str(files[0].relative_to(self.project_dir)))
+ logging.info(f"[DEPLOY] Project file {files[0]} found and set in config file")
+
+ def _find_and_set_excluded_qml_plugins(self):
+ if self.qml_files:
+ self.qml_modules = set(run_qmlimportscanner(qml_files=self.qml_files,
+ dry_run=self.dry_run))
+ self.excluded_qml_plugins = EXCLUDED_QML_PLUGINS.difference(self.qml_modules)
+
+ # needed for dry_run testing
+ self.excluded_qml_plugins = sorted(self.excluded_qml_plugins)
+
+ if self.excluded_qml_plugins:
+ self.set_value("qt", "excluded_qml_plugins", ",".join(self.excluded_qml_plugins))
+
+ def _find_and_set_exe_dir(self):
+ if self.project_dir == Path.cwd():
+ self.exe_dir = self.project_dir.relative_to(Path.cwd())
+ else:
+ self.exe_dir = self.project_dir
+ self.exe_dir = Path(
+ self.set_or_fetch(
+ config_property_val=self.exe_dir, config_property_key="exec_directory"
+ )
+ ).absolute()
+
+ def _find_and_set_pysidemodules(self):
+ self.modules = find_pyside_modules(project_dir=self.project_dir,
+ extra_ignore_dirs=self.extra_ignore_dirs,
+ project_data=self.project_data)
+ logging.info("The following PySide modules were found from the Python files of "
+ f"the project {self.modules}")
+
+ def _find_and_set_qtquick_modules(self):
+ """Identify if QtQuick is used in QML files and add them as dependency
+ """
+ extra_modules = []
+ if not self.qml_modules:
+ self.qml_modules = set(run_qmlimportscanner(qml_files=self.qml_files,
+ dry_run=self.dry_run))
+
+ if "QtQuick" in self.qml_modules:
+ extra_modules.append("Quick")
+
+ if "QtQuick.Controls" in self.qml_modules:
+ extra_modules.append("QuickControls2")
+
+ self.modules += extra_modules
+
+
+class DesktopConfig(Config):
+ """Wrapper class around pysidedeploy.spec, but specific to Desktop deployment
+ """
+ def __init__(self, config_file: Path, source_file: Path, python_exe: Path, dry_run: bool,
+ existing_config_file: bool = False, extra_ignore_dirs: List[str] = None):
+ super().__init__(config_file, source_file, python_exe, dry_run, existing_config_file,
+ extra_ignore_dirs)
+ self.dependency_reader = QtDependencyReader(dry_run=self.dry_run)
+ if self.get_value("qt", "modules"):
+ self.modules = self.get_value("qt", "modules").split(",")
+ else:
+ self._find_and_set_pysidemodules()
+ self._find_and_set_qtquick_modules()
+ self._find_dependent_qt_modules()
+
+ self._qt_plugins = []
+ if self.get_value("qt", "plugins"):
+ self._qt_plugins = self.get_value("qt", "plugins").split(",")
+ else:
+ self.qt_plugins = self.dependency_reader.find_plugin_dependencies(self.modules,
+ python_exe)
+
+ self._permissions = []
+ if sys.platform == "darwin":
+ nuitka_macos_permissions = self.get_value("nuitka", "macos.permissions")
+ if nuitka_macos_permissions:
+ self._permissions = nuitka_macos_permissions.split(",")
+ else:
+ self._find_and_set_permissions()
+
+ @property
+ def qt_plugins(self):
+ return self._qt_plugins
+
+ @qt_plugins.setter
+ def qt_plugins(self, qt_plugins):
+ self._qt_plugins = qt_plugins
+ self.set_value("qt", "plugins", ",".join(qt_plugins))
+
+ @property
+ def permissions(self):
+ return self._permissions
+
+ @permissions.setter
+ def permissions(self, permissions):
+ self._permissions = permissions
+ self.set_value("nuitka", "macos.permissions", ",".join(permissions))
+
+ def _find_dependent_qt_modules(self):
+ """
+ Given pysidedeploy_config.modules, find all the other dependent Qt modules.
+ """
+ all_modules = set(self.modules)
+
+ if not self.dependency_reader.lib_reader:
+ warnings.warn(f"[DEPLOY] Unable to find {self.dependency_reader.lib_reader_name}. This "
+ "tool helps to find the Qt module dependencies of the application. "
+ "Skipping checking for dependencies.", category=RuntimeWarning)
+ return
+
+ for module_name in self.modules:
+ self.dependency_reader.find_dependencies(module=module_name, used_modules=all_modules)
+
+ self.modules = list(all_modules)
+
+ def _find_and_set_permissions(self):
+ """
+ Finds and sets the usage description string required for each permission requested by the
+ macOS application.
+ """
+ permissions = []
+ perm_categories = find_permission_categories(project_dir=self.project_dir,
+ extra_ignore_dirs=self.extra_ignore_dirs,
+ project_data=self.project_data)
+
+ perm_categories_str = ",".join(perm_categories)
+ logging.info(f"[DEPLOY] Usage descriptions for the {perm_categories_str} will be added to "
+ "the Info.plist file of the macOS application bundle")
+
+ # handling permissions
+ for perm_category in perm_categories:
+ if perm_category in PERMISSION_MAP:
+ permissions.append(PERMISSION_MAP[perm_category])
+
+ self.permissions = permissions
diff --git a/sources/pyside-tools/deploy_lib/default.spec b/sources/pyside-tools/deploy_lib/default.spec
new file mode 100644
index 000000000..0a729d585
--- /dev/null
+++ b/sources/pyside-tools/deploy_lib/default.spec
@@ -0,0 +1,97 @@
+[app]
+
+# Title of your application
+title = pyside_app_demo
+
+# Project Directory. The general assumption is that project_dir is the parent directory
+# of input_file
+project_dir =
+
+# Source file path
+input_file =
+
+# Directory where exec is stored
+exec_directory =
+
+# Path to .pyproject project file
+project_file =
+
+# Application icon
+icon =
+
+[python]
+
+# Python path
+python_path =
+
+# python packages to install
+# ordered-set: increase compile time performance of nuitka packaging
+# zstandard: provides final executable size optimization
+packages = Nuitka==2.1
+
+# buildozer: for deploying Android application
+android_packages = buildozer==1.5.0,cython==0.29.33
+
+[qt]
+
+# Comma separated path to QML files required
+# normally all the QML files required by the project are added automatically
+qml_files =
+
+# excluded qml plugin binaries
+excluded_qml_plugins =
+
+# Qt modules used. Comma separated
+modules =
+
+# Qt plugins used by the application
+plugins =
+
+[android]
+
+# path to PySide wheel
+wheel_pyside =
+
+# path to Shiboken wheel
+wheel_shiboken =
+
+# plugins to be copied to libs folder of the packaged application. Comma separated
+plugins =
+
+[nuitka]
+
+# usage description for permissions requested by the app as found in the Info.plist file
+# of the app bundle
+# eg: NSCameraUsageDescription:CameraAccess
+macos.permissions =
+
+# (str) specify any extra nuitka arguments
+# eg: extra_args = --show-modules --follow-stdlib
+extra_args = --quiet --noinclude-qt-translations
+
+[buildozer]
+
+# build mode
+# possible options: [release, debug]
+# release creates an aab, while debug creates an apk
+mode = debug
+
+# contrains path to PySide6 and shiboken6 recipe dir
+recipe_dir =
+
+# path to extra Qt Android jars to be loaded by the application
+jars_dir =
+
+# if empty uses default ndk path downloaded by buildozer
+ndk_path =
+
+# if empty uses default sdk path downloaded by buildozer
+sdk_path =
+
+# other libraries to be loaded. Comma separated.
+# loaded at app startup
+local_libs =
+
+# architecture of deployed platform
+# possible values: ["aarch64", "armv7a", "i686", "x86_64"]
+arch =
diff --git a/sources/pyside-tools/deploy_lib/dependency_util.py b/sources/pyside-tools/deploy_lib/dependency_util.py
new file mode 100644
index 000000000..2d5b188d3
--- /dev/null
+++ b/sources/pyside-tools/deploy_lib/dependency_util.py
@@ -0,0 +1,319 @@
+# Copyright (C) 2024 The Qt Company Ltd.
+# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR LGPL-3.0-only OR GPL-2.0-only OR GPL-3.0-only
+
+import ast
+import re
+import os
+import site
+import json
+import warnings
+import logging
+import shutil
+import sys
+from pathlib import Path
+from typing import List, Set
+from functools import lru_cache
+
+from . import IMPORT_WARNING_PYSIDE, run_command
+
+
+@lru_cache(maxsize=None)
+def get_py_files(project_dir: Path, extra_ignore_dirs: List[Path] = None, project_data=None):
+ """Finds and returns all the Python files in the project
+ """
+ py_candidates = []
+ ignore_dirs = ["__pycache__", "env", "venv", "deployment"]
+
+ if project_data:
+ py_candidates = project_data.python_files
+ ui_candidates = project_data.ui_files
+ qrc_candidates = project_data.qrc_files
+
+ def add_uic_qrc_candidates(candidates, candidate_type):
+ possible_py_candidates = [(file.parent / f"{candidate_type}_{file.stem}.py")
+ for file in candidates
+ if (file.parent / f"{candidate_type}_{file.stem}.py").exists()
+ ]
+
+ if len(possible_py_candidates) != len(candidates):
+ warnings.warn(f"[DEPLOY] The number of {candidate_type} files and their "
+ "corresponding Python files don't match.",
+ category=RuntimeWarning)
+
+ py_candidates.extend(possible_py_candidates)
+
+ if ui_candidates:
+ add_uic_qrc_candidates(ui_candidates, "ui")
+
+ if qrc_candidates:
+ add_uic_qrc_candidates(qrc_candidates, "qrc")
+
+ return py_candidates
+
+ # incase there is not .pyproject file, search all python files in project_dir, except
+ # ignore_dirs
+ if extra_ignore_dirs:
+ ignore_dirs.extend(extra_ignore_dirs)
+
+ # find relevant .py files
+ _walk = os.walk(project_dir)
+ for root, dirs, files in _walk:
+ dirs[:] = [d for d in dirs if d not in ignore_dirs and not d.startswith(".")]
+ for py_file in files:
+ if py_file.endswith(".py"):
+ py_candidates.append(Path(root) / py_file)
+
+ return py_candidates
+
+
+@lru_cache(maxsize=None)
+def get_ast(py_file: Path):
+ """Given a Python file returns the abstract syntax tree
+ """
+ contents = py_file.read_text(encoding="utf-8")
+ try:
+ tree = ast.parse(contents)
+ except SyntaxError:
+ print(f"[DEPLOY] Unable to parse {py_file}")
+ return tree
+
+
+def find_permission_categories(project_dir: Path, extra_ignore_dirs: List[Path] = None,
+ project_data=None):
+ """Given the project directory, finds all the permission categories required by the
+ project. eg: Camera, Bluetooth, Contacts etc.
+
+ Note: This function is only relevant for mac0S deployment.
+ """
+ all_perm_categories = set()
+ mod_pattern = re.compile("Q(?P<mod_name>.*)Permission")
+
+ def pyside_permission_imports(py_file: Path):
+ perm_categories = []
+ try:
+ tree = get_ast(py_file)
+ for node in ast.walk(tree):
+ if isinstance(node, ast.ImportFrom):
+ main_mod_name = node.module
+ if main_mod_name == "PySide6.QtCore":
+ # considers 'from PySide6.QtCore import QtMicrophonePermission'
+ for imported_module in node.names:
+ full_mod_name = imported_module.name
+ match = mod_pattern.search(full_mod_name)
+ if match:
+ mod_name = match.group("mod_name")
+ perm_categories.append(mod_name)
+ continue
+
+ if isinstance(node, ast.Import):
+ for imported_module in node.names:
+ full_mod_name = imported_module.name
+ if full_mod_name == "PySide6":
+ logging.warning(IMPORT_WARNING_PYSIDE.format(str(py_file)))
+ except Exception as e:
+ raise RuntimeError(f"[DEPLOY] Finding permission categories failed on file "
+ f"{str(py_file)} with error {e}")
+
+ return set(perm_categories)
+
+ py_candidates = get_py_files(project_dir, extra_ignore_dirs, project_data)
+ for py_candidate in py_candidates:
+ all_perm_categories = all_perm_categories.union(pyside_permission_imports(py_candidate))
+
+ if not all_perm_categories:
+ ValueError("[DEPLOY] No permission categories were found for macOS app bundle creation.")
+
+ return all_perm_categories
+
+
+def find_pyside_modules(project_dir: Path, extra_ignore_dirs: List[Path] = None,
+ project_data=None):
+ """
+ Searches all the python files in the project to find all the PySide modules used by
+ the application.
+ """
+ all_modules = set()
+ mod_pattern = re.compile("PySide6.Qt(?P<mod_name>.*)")
+
+ def pyside_module_imports(py_file: Path):
+ modules = []
+ try:
+ tree = get_ast(py_file)
+ for node in ast.walk(tree):
+ if isinstance(node, ast.ImportFrom):
+ main_mod_name = node.module
+ if main_mod_name.startswith("PySide6"):
+ if main_mod_name == "PySide6":
+ # considers 'from PySide6 import QtCore'
+ for imported_module in node.names:
+ full_mod_name = imported_module.name
+ if full_mod_name.startswith("Qt"):
+ modules.append(full_mod_name[2:])
+ continue
+
+ # considers 'from PySide6.QtCore import Qt'
+ match = mod_pattern.search(main_mod_name)
+ if match:
+ mod_name = match.group("mod_name")
+ modules.append(mod_name)
+ else:
+ logging.warning((
+ f"[DEPLOY] Unable to find module name from {ast.dump(node)}"))
+
+ if isinstance(node, ast.Import):
+ for imported_module in node.names:
+ full_mod_name = imported_module.name
+ if full_mod_name == "PySide6":
+ logging.warning(IMPORT_WARNING_PYSIDE.format(str(py_file)))
+ except Exception as e:
+ raise RuntimeError(f"[DEPLOY] Finding module import failed on file {str(py_file)} with "
+ f"error {e}")
+
+ return set(modules)
+
+ py_candidates = get_py_files(project_dir, extra_ignore_dirs, project_data)
+ for py_candidate in py_candidates:
+ all_modules = all_modules.union(pyside_module_imports(py_candidate))
+
+ if not all_modules:
+ ValueError("[DEPLOY] No PySide6 modules were found")
+
+ return list(all_modules)
+
+
+class QtDependencyReader:
+ def __init__(self, dry_run: bool = False) -> None:
+ self.dry_run = dry_run
+ self.lib_reader_name = None
+ self.qt_module_path_pattern = None
+ self.lib_pattern = None
+ self.command = None
+ self.qt_libs_dir = None
+
+ if sys.platform == "linux":
+ self.lib_reader_name = "readelf"
+ self.qt_module_path_pattern = "libQt6{module}.so.6"
+ self.lib_pattern = re.compile("libQt6(?P<mod_name>.*).so.6")
+ self.command_args = "-d"
+ elif sys.platform == "darwin":
+ self.lib_reader_name = "dyld_info"
+ self.qt_module_path_pattern = "Qt{module}.framework/Versions/A/Qt{module}"
+ self.lib_pattern = re.compile("@rpath/Qt(?P<mod_name>.*).framework/Versions/A/")
+ self.command_args = "-dependents"
+ elif sys.platform == "win32":
+ self.lib_reader_name = "dumpbin"
+ self.qt_module_path_pattern = "Qt6{module}.dll"
+ self.lib_pattern = re.compile("Qt6(?P<mod_name>.*).dll")
+ self.command_args = "/dependents"
+ else:
+ print(f"[DEPLOY] Deployment on unsupported platfrom {sys.platform}")
+ sys.exit(1)
+
+ self.pyside_install_dir = None
+ self.qt_libs_dir = self.get_qt_libs_dir()
+ self._lib_reader = shutil.which(self.lib_reader_name)
+
+ def get_qt_libs_dir(self):
+ """
+ Finds the path to the Qt libs directory inside PySide6 package installation
+ """
+ for possible_site_package in site.getsitepackages():
+ if possible_site_package.endswith("site-packages"):
+ self.pyside_install_dir = Path(possible_site_package) / "PySide6"
+
+ if not self.pyside_install_dir:
+ print("Unable to find site-packages. Exiting ...")
+ sys.exit(-1)
+
+ if sys.platform == "win32":
+ return self.pyside_install_dir
+
+ return self.pyside_install_dir / "Qt" / "lib" # for linux and macOS
+
+ @property
+ def lib_reader(self):
+ return self._lib_reader
+
+ def find_dependencies(self, module: str, used_modules: Set[str] = None):
+ """
+ Given a Qt module, find all the other Qt modules it is dependent on and add it to the
+ 'used_modules' set
+ """
+ qt_module_path = self.qt_libs_dir / self.qt_module_path_pattern.format(module=module)
+ if not qt_module_path.exists():
+ warnings.warn(f"[DEPLOY] {qt_module_path.name} not found in {str(qt_module_path)}."
+ "Skipping finding its dependencies.", category=RuntimeWarning)
+ return
+
+ lib_pattern = re.compile(self.lib_pattern)
+ command = [self.lib_reader, self.command_args, str(qt_module_path)]
+ # print the command if dry_run is True.
+ # Normally run_command is going to print the command in dry_run mode. But, this is a
+ # special case where we need to print the command as well as to run it.
+ if self.dry_run:
+ command_str = " ".join(command)
+ print(command_str + "\n")
+
+ # We need to run this even for dry run, to see the full Nuitka command being executed
+ _, output = run_command(command=command, dry_run=False, fetch_output=True)
+
+ dependent_modules = set()
+ for line in output.splitlines():
+ line = line.decode("utf-8").lstrip()
+ if sys.platform == "darwin":
+ if line.endswith(f"Qt{module} [arm64]:"):
+ # macOS Qt frameworks bundles have both x86_64 and arm64 architectures
+ # We only need to consider one as the dependencies are redundant
+ break
+ elif line.endswith(f"Qt{module} [X86_64]:"):
+ # this line needs to be skipped because it matches with the pattern
+ # and is related to the module itself, not the dependencies of the module
+ continue
+ elif sys.platform == "win32" and line.startswith("Summary"):
+ # the dependencies would be found before the `Summary` line
+ break
+ match = lib_pattern.search(line)
+ if match:
+ dep_module = match.group("mod_name")
+ dependent_modules.add(dep_module)
+ if dep_module not in used_modules:
+ used_modules.add(dep_module)
+ self.find_dependencies(module=dep_module, used_modules=used_modules)
+
+ if dependent_modules:
+ logging.info(f"[DEPLOY] Following dependencies found for {module}: {dependent_modules}")
+ else:
+ logging.info(f"[DEPLOY] No Qt dependencies found for {module}")
+
+ def find_plugin_dependencies(self, used_modules: List[str], python_exe: Path) -> List[str]:
+ """
+ Given the modules used by the application, returns all the required plugins
+ """
+ plugins = set()
+ pyside_wheels = ["PySide6_Essentials", "PySide6_Addons"]
+ # TODO from 3.12 use list(dist.name for dist in importlib.metadata.distributions())
+ _, installed_packages = run_command(command=[str(python_exe), "-m", "pip", "freeze"],
+ dry_run=False, fetch_output=True)
+ installed_packages = [p.decode().split('==')[0] for p in installed_packages.split()]
+ for pyside_wheel in pyside_wheels:
+ if pyside_wheel not in installed_packages:
+ # the wheel is not installed and hence no plugins are checked for its modules
+ logging.warning((f"[DEPLOY] The package {pyside_wheel} is not installed. "))
+ continue
+ pyside_mod_plugin_json_name = f"{pyside_wheel}.json"
+ pyside_mod_plugin_json_file = self.pyside_install_dir / pyside_mod_plugin_json_name
+ if not pyside_mod_plugin_json_file.exists():
+ warnings.warn(f"[DEPLOY] Unable to find {pyside_mod_plugin_json_file}.",
+ category=RuntimeWarning)
+ continue
+
+ # convert the json to dict
+ pyside_mod_dict = {}
+ with open(pyside_mod_plugin_json_file) as pyside_json:
+ pyside_mod_dict = json.load(pyside_json)
+
+ # find all the plugins in the modules
+ for module in used_modules:
+ plugins.update(pyside_mod_dict.get(module, []))
+
+ return list(plugins)
diff --git a/sources/pyside-tools/deploy_lib/deploy_util.py b/sources/pyside-tools/deploy_lib/deploy_util.py
new file mode 100644
index 000000000..e8b05e990
--- /dev/null
+++ b/sources/pyside-tools/deploy_lib/deploy_util.py
@@ -0,0 +1,77 @@
+# Copyright (C) 2023 The Qt Company Ltd.
+# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR LGPL-3.0-only OR GPL-2.0-only OR GPL-3.0-only
+
+import logging
+import shutil
+import sys
+from pathlib import Path
+
+from . import EXE_FORMAT
+from .config import Config
+
+
+def config_option_exists():
+ for argument in sys.argv:
+ if any(item in argument for item in ["--config-file", "-c"]):
+ return True
+
+ return False
+
+
+def cleanup(config: Config, is_android: bool = False):
+ """
+ Cleanup the generated build folders/files
+ """
+ if config.generated_files_path.exists():
+ shutil.rmtree(config.generated_files_path)
+ logging.info("[DEPLOY] Deployment directory purged")
+
+ if is_android:
+ buildozer_spec: Path = config.project_dir / "buildozer.spec"
+ if buildozer_spec.exists():
+ buildozer_spec.unlink()
+ logging.info(f"[DEPLOY] {str(buildozer_spec)} removed")
+
+ buildozer_build: Path = config.project_dir / ".buildozer"
+ if buildozer_build.exists():
+ shutil.rmtree(buildozer_build)
+ logging.info(f"[DEPLOY] {str(buildozer_build)} removed")
+
+
+def create_config_file(dry_run: bool = False, config_file: Path = None, main_file: Path = None):
+ """
+ Sets up a new pysidedeploy.spec or use an existing config file
+ """
+
+ if main_file:
+ if main_file.parent != Path.cwd():
+ config_file = main_file.parent / "pysidedeploy.spec"
+ else:
+ config_file = Path.cwd() / "pysidedeploy.spec"
+
+ logging.info(f"[DEPLOY] Creating config file {config_file}")
+ if not dry_run:
+ shutil.copy(Path(__file__).parent / "default.spec", config_file)
+
+ # the config parser needs a reference to parse. So, in the case of --dry-run
+ # use the default.spec file.
+ if dry_run:
+ config_file = Path(__file__).parent / "default.spec"
+
+ return config_file
+
+
+def finalize(config: Config):
+ """
+ Copy the executable into the final location
+ For Android deployment, this is done through buildozer
+ """
+ generated_exec_path = config.generated_files_path / (config.source_file.stem + EXE_FORMAT)
+ if generated_exec_path.exists() and config.exe_dir:
+ if sys.platform == "darwin":
+ shutil.copytree(generated_exec_path, config.exe_dir / (config.title + EXE_FORMAT),
+ dirs_exist_ok=True)
+ else:
+ shutil.copy(generated_exec_path, config.exe_dir)
+ print("[DEPLOY] Executed file created in "
+ f"{str(config.exe_dir / (config.source_file.stem + EXE_FORMAT))}")
diff --git a/sources/pyside-tools/deploy_lib/nuitka_helper.py b/sources/pyside-tools/deploy_lib/nuitka_helper.py
new file mode 100644
index 000000000..ac9a83f3f
--- /dev/null
+++ b/sources/pyside-tools/deploy_lib/nuitka_helper.py
@@ -0,0 +1,118 @@
+# Copyright (C) 2022 The Qt Company Ltd.
+# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR LGPL-3.0-only OR GPL-2.0-only OR GPL-3.0-only
+
+import logging
+import os
+import sys
+from pathlib import Path
+from typing import List
+
+from . import MAJOR_VERSION, run_command
+
+
+class Nuitka:
+ """
+ Wrapper class around the nuitka executable, enabling its usage through python code
+ """
+
+ def __init__(self, nuitka):
+ self.nuitka = nuitka
+ # plugins to ignore. The sensible plugins are include by default by Nuitka for PySide6
+ # application deployment
+ self.qt_plugins_to_ignore = ["imageformats", # being Nuitka `sensible`` plugins
+ "iconengines",
+ "mediaservice",
+ "printsupport",
+ "platforms",
+ "platformthemes",
+ "styles",
+ "wayland-shell-integration",
+ "wayland-decoration-client",
+ "wayland-graphics-integration-client",
+ "egldeviceintegrations",
+ "xcbglintegrations",
+ "tls", # end Nuitka `sensible` plugins
+ "generic" # plugins that error with Nuitka
+ ]
+
+ # .webp are considered to be dlls by Nuitka instead of data files causing
+ # the packaging to fail
+ # https://github.com/Nuitka/Nuitka/issues/2854
+ # TODO: Remove .webp when the issue is fixed
+ self.files_to_ignore = [".cpp.o", ".qsb", ".webp"]
+
+ @staticmethod
+ def icon_option():
+ if sys.platform == "linux":
+ return "--linux-icon"
+ elif sys.platform == "win32":
+ return "--windows-icon-from-ico"
+ else:
+ return "--macos-app-icon"
+
+ def create_executable(self, source_file: Path, extra_args: str, qml_files: List[Path],
+ qt_plugins: List[str], excluded_qml_plugins: List[str], icon: str,
+ dry_run: bool, permissions: List[str]):
+ qt_plugins = [plugin for plugin in qt_plugins if plugin not in self.qt_plugins_to_ignore]
+ extra_args = extra_args.split()
+
+ if sys.platform == "darwin":
+ # create an app bundle
+ extra_args.extend(["--standalone", "--macos-create-app-bundle"])
+ permission_pattern = "--macos-app-protected-resource={permission}"
+ for permission in permissions:
+ extra_args.append(permission_pattern.format(permission=permission))
+ else:
+ extra_args.append("--onefile")
+
+ qml_args = []
+ if qml_files:
+ # This will generate options for each file using:
+ # --include-data-files=ABSOLUTE_PATH_TO_FILE=RELATIVE_PATH_TO ROOT
+ # for each file. This will preserve the directory structure of QML resources.
+ qml_args.extend(
+ [f"--include-data-files={qml_file.resolve()}="
+ f"./{qml_file.resolve().relative_to(source_file.parent)}"
+ for qml_file in qml_files]
+ )
+ # add qml plugin. The `qml`` plugin name is not present in the module json files shipped
+ # with Qt and hence not in `qt_plugins``. However, Nuitka uses the 'qml' plugin name to
+ # include the necessary qml plugins. There we have to add it explicitly for a qml
+ # application
+ qt_plugins.append("qml")
+
+ if excluded_qml_plugins:
+ prefix = "lib" if sys.platform != "win32" else ""
+ for plugin in excluded_qml_plugins:
+ dll_name = plugin.replace("Qt", f"Qt{MAJOR_VERSION}")
+ qml_args.append(f"--noinclude-dlls={prefix}{dll_name}*")
+
+ # Exclude .qen json files from QtQuickEffectMaker
+ # These files are not relevant for PySide6 applications
+ qml_args.append("--noinclude-dlls=*/qml/QtQuickEffectMaker/*")
+
+ # Exclude files that cannot be processed by Nuitka
+ for file in self.files_to_ignore:
+ extra_args.append(f"--noinclude-dlls=*{file}")
+
+ output_dir = source_file.parent / "deployment"
+ if not dry_run:
+ output_dir.mkdir(parents=True, exist_ok=True)
+ logging.info("[DEPLOY] Running Nuitka")
+ command = self.nuitka + [
+ os.fspath(source_file),
+ "--follow-imports",
+ "--enable-plugin=pyside6",
+ f"--output-dir={output_dir}",
+ ]
+
+ command.extend(extra_args + qml_args)
+ command.append(f"{self.__class__.icon_option()}={icon}")
+ if qt_plugins:
+ # sort qt_plugins so that the result is definitive when testing
+ qt_plugins.sort()
+ qt_plugins_str = ",".join(qt_plugins)
+ command.append(f"--include-qt-plugins={qt_plugins_str}")
+
+ command_str, _ = run_command(command=command, dry_run=dry_run)
+ return command_str
diff --git a/sources/pyside-tools/deploy_lib/pyside_icon.icns b/sources/pyside-tools/deploy_lib/pyside_icon.icns
new file mode 100644
index 000000000..a6eb02bb0
--- /dev/null
+++ b/sources/pyside-tools/deploy_lib/pyside_icon.icns
Binary files differ
diff --git a/sources/pyside-tools/deploy_lib/pyside_icon.ico b/sources/pyside-tools/deploy_lib/pyside_icon.ico
new file mode 100644
index 000000000..332a3a568
--- /dev/null
+++ b/sources/pyside-tools/deploy_lib/pyside_icon.ico
Binary files differ
diff --git a/sources/pyside-tools/deploy_lib/pyside_icon.jpg b/sources/pyside-tools/deploy_lib/pyside_icon.jpg
new file mode 100644
index 000000000..647c42c71
--- /dev/null
+++ b/sources/pyside-tools/deploy_lib/pyside_icon.jpg
Binary files differ
diff --git a/sources/pyside-tools/deploy_lib/python_helper.py b/sources/pyside-tools/deploy_lib/python_helper.py
new file mode 100644
index 000000000..7cbf323ed
--- /dev/null
+++ b/sources/pyside-tools/deploy_lib/python_helper.py
@@ -0,0 +1,122 @@
+# Copyright (C) 2022 The Qt Company Ltd.
+# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR LGPL-3.0-only OR GPL-2.0-only OR GPL-3.0-only
+
+import logging
+import os
+import sys
+
+from importlib import util
+from importlib.metadata import version
+from pathlib import Path
+
+from . import Config, run_command
+
+
+class PythonExecutable:
+ """
+ Wrapper class around Python executable
+ """
+
+ def __init__(self, python_path: Path = None, dry_run: bool = False, init: bool = False,
+ force: bool = False):
+
+ self.dry_run = dry_run
+ self.init = init
+ if not python_path:
+ response = "yes"
+ # checking if inside virtual environment
+ if not self.is_venv() and not force and not self.dry_run and not self.init:
+ response = input(("You are not using a virtual environment. pyside6-deploy needs "
+ "to install a few Python packages for deployment to work "
+ "seamlessly. \n Proceed? [Y/n]"))
+
+ if response.lower() in ["no", "n"]:
+ print("[DEPLOY] Exiting ...")
+ sys.exit(0)
+
+ self.exe = Path(sys.executable)
+ else:
+ self.exe = python_path
+
+ logging.info(f"[DEPLOY] Using Python at {str(self.exe)}")
+
+ @property
+ def exe(self):
+ return Path(self._exe)
+
+ @exe.setter
+ def exe(self, exe):
+ self._exe = exe
+
+ @staticmethod
+ def is_venv():
+ venv = os.environ.get("VIRTUAL_ENV")
+ return True if venv else False
+
+ def is_pyenv_python(self):
+ pyenv_root = os.environ.get("PYENV_ROOT")
+
+ if pyenv_root:
+ resolved_exe = self.exe.resolve()
+ if str(resolved_exe).startswith(pyenv_root):
+ return True
+
+ return False
+
+ def install(self, packages: list = None):
+ _, installed_packages = run_command(command=[str(self.exe), "-m", "pip", "freeze"],
+ dry_run=False, fetch_output=True)
+ installed_packages = [p.decode().split('==')[0] for p in installed_packages.split()]
+ for package in packages:
+ package_info = package.split('==')
+ package_components_len = len(package_info)
+ package_name, package_version = None, None
+ if package_components_len == 1:
+ package_name = package_info[0]
+ elif package_components_len == 2:
+ package_name = package_info[0]
+ package_version = package_info[1]
+ else:
+ raise ValueError(f"{package} should be of the format 'package_name'=='version'")
+ if (package_name not in installed_packages) and (not self.is_installed(package_name)):
+ logging.info(f"[DEPLOY] Installing package: {package}")
+ run_command(
+ command=[self.exe, "-m", "pip", "install", package],
+ dry_run=self.dry_run,
+ )
+ elif package_version:
+ installed_version = version(package_name)
+ if package_version != installed_version:
+ logging.info(f"[DEPLOY] Installing package: {package_name}"
+ f"version: {package_version}")
+ run_command(
+ command=[self.exe, "-m", "pip", "install", "--force", package],
+ dry_run=self.dry_run,
+ )
+ else:
+ logging.info(f"[DEPLOY] package: {package_name}=={package_version}"
+ " already installed")
+ else:
+ logging.info(f"[DEPLOY] package: {package_name} already installed")
+
+ def is_installed(self, package):
+ return bool(util.find_spec(package))
+
+ def install_dependencies(self, config: Config, packages: str, is_android: bool = False):
+ """
+ Installs the python package dependencies for the target deployment platform
+ """
+ packages = config.get_value("python", packages).split(",")
+ if not self.init:
+ # install packages needed for deployment
+ logging.info("[DEPLOY] Installing dependencies")
+ self.install(packages=packages)
+ # nuitka requires patchelf to make patchelf rpath changes for some Qt files
+ if sys.platform.startswith("linux") and not is_android:
+ self.install(packages=["patchelf"])
+ elif is_android:
+ # install only buildozer
+ logging.info("[DEPLOY] Installing buildozer")
+ buildozer_package_with_version = ([package for package in packages
+ if package.startswith("buildozer")])
+ self.install(packages=list(buildozer_package_with_version))