diff options
Diffstat (limited to 'sources/pyside-tools/deploy_lib')
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 Binary files differnew file mode 100644 index 000000000..a6eb02bb0 --- /dev/null +++ b/sources/pyside-tools/deploy_lib/pyside_icon.icns diff --git a/sources/pyside-tools/deploy_lib/pyside_icon.ico b/sources/pyside-tools/deploy_lib/pyside_icon.ico Binary files differnew file mode 100644 index 000000000..332a3a568 --- /dev/null +++ b/sources/pyside-tools/deploy_lib/pyside_icon.ico diff --git a/sources/pyside-tools/deploy_lib/pyside_icon.jpg b/sources/pyside-tools/deploy_lib/pyside_icon.jpg Binary files differnew file mode 100644 index 000000000..647c42c71 --- /dev/null +++ b/sources/pyside-tools/deploy_lib/pyside_icon.jpg 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)) |