diff options
Diffstat (limited to 'sources/pyside-tools/deploy_lib')
9 files changed, 426 insertions, 11 deletions
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..12eb6830c --- /dev/null +++ b/sources/pyside-tools/deploy_lib/android/__init__.py @@ -0,0 +1,10 @@ +# 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 + +WIDGET_APPLICATION_MODULES = ["Core", "Gui", "Widgets"] +QUICK_APPLICATION_MODULES = ["Core", "Gui", "Widgets", "Network", "OpenGL", "Qml", "Quick", + "QuickControls2"] + +from .android_helper import (create_recipe, extract_and_copy_jar, get_wheel_android_arch, + AndroidData) +from .buildozer import Buildozer 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..b0c8254ab --- /dev/null +++ b/sources/pyside-tools/deploy_lib/android/android_helper.py @@ -0,0 +1,59 @@ +# 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 +from pathlib import Path +from jinja2 import Environment, FileSystemLoader +from zipfile import ZipFile +from dataclasses import dataclass + + +@dataclass +class AndroidData: + 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): + ''' + Create python_for_android recipe for PySide6 and shiboken6 + ''' + 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, + ) + + recipe_path = generated_files_path / "recipes" / f"{component}" + recipe_path.mkdir(parents=True, exist_ok=True) + logging.info(f"Writing {component} recipe into {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 later use 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 + + +def get_wheel_android_arch(wheel: str): + wheel = Path(wheel) + supported_archs = ["aarch64", "armv7a", "i686", "x86_64"] + for arch in supported_archs: + if arch in wheel.stem: + return arch + + return None 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..91d55f4ca --- /dev/null +++ b/sources/pyside-tools/deploy_lib/android/buildozer.py @@ -0,0 +1,84 @@ +# 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 +from pathlib import Path +from .. import run_command, BaseConfig, Config + + +class BuildozerConfig(BaseConfig): + def __init__(self, buildozer_spec_file: Path, pysidedeploy_config: Config, dry_run: bool): + super().__init__(buildozer_spec_file, dry_run, 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" + self.set_value("app", "source.include_exts", include_exts) + + self.set_value("app", "requirements", "python3,shiboken6,PySide6") + + 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.add_jars", f"{str(pysidedeploy_config.jars_dir)}/*.jar") + + platform_map = {"aarch64": "arm64-v8a", + "armv7a": "armeabi-v7a", + "i686": "x86", + "x86_64": "x86_64"} + arch = platform_map[pysidedeploy_config.arch] + self.set_value("app", "android.archs", arch) + + # p4a changes + logging.info("[DEPLOY] Using custom fork of python-for-android: " + "https://github.com/shyamnathp/python-for-android/tree/pyside_support") + self.set_value("app", "p4a.fork", "shyamnathp") + self.set_value("app", "p4a.branch", "pyside_support") + self.set_value('app', "p4a.local_recipes", str(pysidedeploy_config.recipe_dir)) + self.set_value("app", "p4a.bootstrap", "qt") + + modules = ",".join(pysidedeploy_config.modules) + local_libs = ",".join(pysidedeploy_config.local_libs) + extra_args = (f"--qt-libs={modules} --load-local-libs={local_libs}") + 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())) + + self.update_config() + + +class Buildozer: + dry_run = False + + @staticmethod + def initialize(pysidedeploy_config: Config): + 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 = ["buildozer", "init"] + run_command(command=command, dry_run=Buildozer.dry_run) + if not Buildozer.dry_run and not buildozer_spec.exists(): + raise RuntimeError(f"buildozer.spec not found in {Path.cwd()}") + BuildozerConfig(buildozer_spec, pysidedeploy_config, Buildozer.dry_run) + + @staticmethod + def create_executable(mode: str): + # build the application in release mode + command = ["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..289f2d62b --- /dev/null +++ b/sources/pyside-tools/deploy_lib/android/recipes/PySide6/__init__.tmpl.py @@ -0,0 +1,43 @@ +# 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 + +from pythonforandroid.recipe import PythonRecipe +from pythonforandroid.logger import info +import zipfile +import shutil +from pathlib import Path + + +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("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)}/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) + + 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("Copying Qt platform plugin to be loaded on startup from SDK to be loaded on startup") + shutil.copyfile( + Path(self.ctx.get_python_install_dir(arch.arch)) + / "PySide6" / "Qt" / "plugins" / "platforms" + / f"libplugins_platforms_qtforandroid_{arch.arch}.so", + Path(self.ctx.get_libs_dir(arch.arch)) / f"libplugins_platforms_qtforandroid_{arch.arch}.so", + ) + + +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..afe094cbd --- /dev/null +++ b/sources/pyside-tools/deploy_lib/android/recipes/shiboken6/__init__.tmpl.py @@ -0,0 +1,24 @@ +# 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 + +from pythonforandroid.recipe import PythonRecipe +from pythonforandroid.logger import info +import zipfile + + +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)) + + +recipe = ShibokenRecipe() diff --git a/sources/pyside-tools/deploy_lib/config.py b/sources/pyside-tools/deploy_lib/config.py index 2575ab063..781c2155d 100644 --- a/sources/pyside-tools/deploy_lib/config.py +++ b/sources/pyside-tools/deploy_lib/config.py @@ -19,7 +19,7 @@ class BaseConfig: def __init__(self, config_file: Path, dry_run: bool, comment_prefixes: str = "/") -> None: self.config_file = config_file - self.parser = ConfigParser(comment_prefixes="/", allow_no_value=True) + self.parser = ConfigParser(comment_prefixes=comment_prefixes, allow_no_value=True) if not self.config_file.exists(): if not dry_run: logging.info(f"[DEPLOY] Creating config file {self.config_file}") @@ -62,7 +62,8 @@ class Config(BaseConfig): creation """ - def __init__(self, config_file: Path, source_file: Path, python_exe: Path, dry_run: bool): + def __init__(self, config_file: Path, source_file: Path, python_exe: Path, dry_run: bool, + android_data, is_android: bool): super().__init__(config_file, dry_run) self._dry_run = dry_run @@ -81,6 +82,7 @@ class Config(BaseConfig): ) self.title = self.get_value("app", "title") + self.project_dir = None if self.get_value("app", "project_dir"): self.project_dir = Path(self.get_value("app", "project_dir")).absolute() @@ -113,6 +115,50 @@ class Config(BaseConfig): else: self._find_and_set_excluded_qml_plugins() + # Android + if is_android: + if android_data.wheel_pyside: + self.wheel_pyside = android_data.wheel_pyside + else: + wheel_pyside_temp = self.get_value("qt", "wheel_pyside") + self.wheel_pyside = Path(wheel_pyside_temp) if wheel_pyside_temp else None + + if android_data.wheel_shiboken: + self.wheel_shiboken = android_data.wheel_shiboken + else: + wheel_shiboken_temp = self.get_value("qt", "wheel_shiboken") + self.wheel_shiboken = Path(wheel_shiboken_temp) if wheel_shiboken_temp else None + + if android_data.ndk_path: + self.ndk_path = android_data.ndk_path + else: + ndk_path_temp = self.get_value("buildozer", "ndk_path") + self.ndk_path = Path(ndk_path_temp) if ndk_path_temp else None + + if android_data.sdk_path: + self.sdk_path = android_data.sdk_path + else: + sdk_path_temp = self.get_value("buildozer", "sdk_path") + self.sdk_path = Path(sdk_path_temp) if sdk_path_temp else None + + recipe_dir_temp = self.get_value("buildozer", "recipe_dir") + self.recipe_dir = Path(recipe_dir_temp) if recipe_dir_temp else None + + jars_dir_temp = self.get_value("buildozer", "jars_dir") + self.jars_dir = Path(jars_dir_temp) if jars_dir_temp else None + + self._modules = [] + if self.get_value("buildozer", "modules"): + self.modules = self.get_value("buildozer", "modules").split(",") + + self.arch = self.get_value("buildozer", "arch") + + self._local_libs = [] + if self.get_value("buildozer", "local_libs"): + self.local_libs = self.get_value("buildozer", "local_libs").split(",") + + self._mode = self.get_value("buildozer", "mode") + 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 @@ -164,7 +210,7 @@ class Config(BaseConfig): return self._source_file @source_file.setter - def source_file(self, source_file): + def source_file(self, source_file: Path): self._source_file = source_file @property @@ -172,7 +218,7 @@ class Config(BaseConfig): return self._python_path @python_path.setter - def python_path(self, python_path): + def python_path(self, python_path: Path): self._python_path = python_path @property @@ -184,13 +230,113 @@ class Config(BaseConfig): self._excluded_qml_plugins = excluded_qml_plugins @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 modules(self): + return self._modules + + @modules.setter + def modules(self, modules): + self._modules = modules + self.set_value("buildozer", "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 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 exe_dir(self): return self._exe_dir @exe_dir.setter - def exe_dir(self, exe_dir): + def exe_dir(self, exe_dir: Path): self._exe_dir = exe_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("qt", "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("qt", "wheel_shiboken", str(self._wheel_shiboken)) + 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""" diff --git a/sources/pyside-tools/deploy_lib/default.spec b/sources/pyside-tools/deploy_lib/default.spec index 17027e898..fdfb5397d 100644 --- a/sources/pyside-tools/deploy_lib/default.spec +++ b/sources/pyside-tools/deploy_lib/default.spec @@ -26,6 +26,9 @@ python_path = # zstandard: provides final executable size optimization packages = nuitka==1.5.4,ordered_set,zstandard +# buildozer: for deploying Android application +android_packages = buildozer==1.5.0,cython==0.29.33 + [qt] # Comma separated path to QML files required @@ -35,8 +38,43 @@ qml_files = # excluded qml plugin binaries excluded_qml_plugins = +# path to PySide wheel +wheel_pyside = + +# path to Shiboken wheel +wheel_shiboken = + [nuitka] # (str) specify any extra nuitka arguments # eg: extra_args = --show-modules --follow-stdlib extra_args = --quiet --noinclude-qt-translations=True + +[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 = + +# modules used. Comma separated +modules = + +# other libraries to be loaded. Comma separated. +local_libs = plugins_platforms_qtforandroid + +# architecture of deployed platform +# possible values: ["aarch64", "armv7a", "i686", "x86_64"] +arch = diff --git a/sources/pyside-tools/deploy_lib/deploy_util.py b/sources/pyside-tools/deploy_lib/deploy_util.py index 843a21f5a..de545e3a5 100644 --- a/sources/pyside-tools/deploy_lib/deploy_util.py +++ b/sources/pyside-tools/deploy_lib/deploy_util.py @@ -19,7 +19,7 @@ def config_option_exists(): return False -def cleanup(generated_files_path: Path, config: Config): +def cleanup(generated_files_path: Path, config: Config, is_android: bool = False): """ Cleanup the generated build folders/files """ @@ -29,9 +29,20 @@ def cleanup(generated_files_path: Path, config: Config): elif not config.dry_run: logging.info(f"[DEPLOY] {generated_files_path} does not exist") + 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 get_config(python_exe: Path, dry_run: bool = False, config_file: Path = None, main_file: - Path = None): + Path = None, android_data = None, is_android: bool = False): """ Sets up a new deployment configuration or use an existing config file """ @@ -42,7 +53,7 @@ def get_config(python_exe: Path, dry_run: bool = False, config_file: Path = None config_file = Path.cwd() / "pysidedeploy.spec" config = Config(config_file=config_file, source_file=main_file, python_exe=python_exe, - dry_run=dry_run) + dry_run=dry_run, android_data=android_data, is_android=is_android) return config @@ -70,7 +81,7 @@ def setup_python(dry_run: bool, force: bool, init: bool): def install_python_dependencies(config: Config, python: PythonExecutable, init: bool, - packages: str): + packages: str, is_android: bool = False): """ Installs the python package dependencies for the target deployment platform """ @@ -80,13 +91,14 @@ def install_python_dependencies(config: Config, python: PythonExecutable, init: packages = config.get_value("python", packages).split(",") python.install(packages=packages) # nuitka requires patchelf to make patchelf rpath changes for some Qt files - if sys.platform.startswith("linux"): + if sys.platform.startswith("linux") and not is_android: python.install(packages=["patchelf"]) def finalize(generated_files_path: Path, config: Config): """ Copy the executable into the final location + For Android deployment, this is done through buildozer """ generated_exec_path = generated_files_path / (config.source_file.stem + EXE_FORMAT) if generated_exec_path.exists() and config.exe_dir: diff --git a/sources/pyside-tools/deploy_lib/python_helper.py b/sources/pyside-tools/deploy_lib/python_helper.py index 2fc0236bc..e86ce2e1c 100644 --- a/sources/pyside-tools/deploy_lib/python_helper.py +++ b/sources/pyside-tools/deploy_lib/python_helper.py @@ -49,7 +49,6 @@ class PythonExecutable: package_version = package_info[1] else: raise ValueError(f"{package} should be of the format 'package_name'=='version'") - if not self.is_installed(package=package_name): logging.info(f"[DEPLOY] Installing package: {package}") run_command( |
