diff options
| author | Shyamnath Premnadh <Shyamnath.Premnadh@qt.io> | 2023-02-08 16:58:39 +0100 |
|---|---|---|
| committer | Qt Cherry-pick Bot <cherrypick_bot@qt-project.org> | 2023-03-31 14:21:05 +0000 |
| commit | 2e458381e4847f46814518da9f7cfca097aa8bc4 (patch) | |
| tree | bd31c54bb24c26b5ba6b5a3fb815c7b44f625a4f | |
| parent | 22f35d990b5cb5b65a08823128ec42305822c21a (diff) | |
Deployment: New pyside6-android-deploy tool
- Preliminary support for PySide6 Android deployment
- Uses jinja2 to create PySide6 and shiboken6 recipes, to be used
by buildozer when python_for_android builds the app distribution
- Classes for Buildozer config interaction
- Run deployment to android. Typical command looks like:
"""
pyside6-android-deploy
--wheel-pyside=./PySide6-6.5.0a1-6.5.0-cp37-abi3-android_x86_64.whl
--wheel-shiboken=./shiboken6-6.5.0a1-6.5.0-cp37-abi3-android_x86_64.whl
--name=stringlistmodel
"""
- New entrypoint for pyside6-android-deploy
- Helper functinos for Android Deployment
- Remove unused function main_py_exists()
- Added the new files to deploy.pyproject
- Remove dry_run argument from install_python_dependencies()
- new Python packages added in requirements.txt to enable the
deploy and cross compile tool
Note: python-for-android uses my local fork. This will be changed
once it is merged into python-for-android dev.
Task-number: PYSIDE-1612
Change-Id: I7eb96fa5507a476b4e86ec0195a5e9869f0f85fd
Reviewed-by: Cristian Maureira-Fredes <cristian.maureira-fredes@qt.io>
Reviewed-by: Qt CI Bot <qt_ci_bot@qt-project.org>
(cherry picked from commit 95abfa776411b6d7cd4296adf63bc7abce2270b6)
Reviewed-by: Qt Cherry-pick Bot <cherrypick_bot@qt-project.org>
| -rw-r--r-- | build_scripts/__init__.py | 1 | ||||
| -rw-r--r-- | build_scripts/config.py | 20 | ||||
| -rw-r--r-- | build_scripts/platforms/unix.py | 16 | ||||
| -rw-r--r-- | create_wheels.py | 13 | ||||
| -rw-r--r-- | requirements.txt | 5 | ||||
| -rw-r--r-- | sources/pyside-tools/CMakeLists.txt | 3 | ||||
| -rw-r--r-- | sources/pyside-tools/android_deploy.py | 233 | ||||
| -rw-r--r-- | sources/pyside-tools/android_deploy.pyproject | 9 | ||||
| -rw-r--r-- | sources/pyside-tools/deploy_lib/android/__init__.py | 10 | ||||
| -rw-r--r-- | sources/pyside-tools/deploy_lib/android/android_helper.py | 59 | ||||
| -rw-r--r-- | sources/pyside-tools/deploy_lib/android/buildozer.py | 84 | ||||
| -rw-r--r-- | sources/pyside-tools/deploy_lib/android/recipes/PySide6/__init__.tmpl.py | 43 | ||||
| -rw-r--r-- | sources/pyside-tools/deploy_lib/android/recipes/shiboken6/__init__.tmpl.py | 24 | ||||
| -rw-r--r-- | sources/pyside-tools/deploy_lib/config.py | 156 | ||||
| -rw-r--r-- | sources/pyside-tools/deploy_lib/default.spec | 38 | ||||
| -rw-r--r-- | sources/pyside-tools/deploy_lib/deploy_util.py | 22 | ||||
| -rw-r--r-- | sources/pyside-tools/deploy_lib/python_helper.py | 1 | ||||
| -rw-r--r-- | sources/pyside-tools/pyside_tool.py | 4 |
18 files changed, 719 insertions, 22 deletions
diff --git a/build_scripts/__init__.py b/build_scripts/__init__.py index ac76724b0..ba7888bdb 100644 --- a/build_scripts/__init__.py +++ b/build_scripts/__init__.py @@ -7,6 +7,7 @@ SHIBOKEN = 'shiboken6' PYSIDE_PYTHON_TOOLS = ["metaobjectdump", "deploy", + "android_deploy", "project", "qml", "qtpy2cpp", diff --git a/build_scripts/config.py b/build_scripts/config.py index 1f01ccdd4..1f2f0b25c 100644 --- a/build_scripts/config.py +++ b/build_scripts/config.py @@ -1,6 +1,7 @@ # Copyright (C) 2018 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 .log import log, LogLevel from pathlib import Path @@ -201,11 +202,20 @@ class Config(object): ] if qt_install_path: _pyside_tools = available_pyside_tools(qt_tools_path=qt_install_path) - setup_kwargs['entry_points'] = { - 'console_scripts': [f'{PYSIDE}-{tool} = {package_name}.scripts.pyside_tool:' - f'{tool}' - for tool in _pyside_tools] - } + + # replacing pyside6-android_deploy by pyside6-android-deploy for consistency + # Also, the tool should not exist in any other platform than Linux + _console_scripts = [] + if ("android_deploy" in _pyside_tools) and sys.platform.startswith("linux"): + _console_scripts = [(f"{PYSIDE}-android-deploy =" + " PySide6.scripts.pyside_tool:android_deploy")] + _pyside_tools.remove("android_deploy") + + _console_scripts.extend([f'{PYSIDE}-{tool} = {package_name}.scripts.pyside_tool:' + f'{tool}' for tool in _pyside_tools]) + + setup_kwargs['entry_points'] = {'console_scripts': _console_scripts} + self.setup_kwargs = setup_kwargs def get_long_description(self): diff --git a/build_scripts/platforms/unix.py b/build_scripts/platforms/unix.py index 0c26297e2..98c2945ce 100644 --- a/build_scripts/platforms/unix.py +++ b/build_scripts/platforms/unix.py @@ -119,14 +119,24 @@ def prepare_packages_posix(pyside_build, _vars, cross_build=False): "{st_build_dir}/{st_package_name}/scripts/__init__.py", _vars=_vars) + scripts = ["pyside_tool.py", "metaobjectdump.py", "project.py", "qml.py", + "qtpy2cpp.py", "deploy.py"] + + script_dirs = ["qtpy2cpp_lib", "deploy_lib", "project"] + + if sys.platform.startswith("linux"): + scripts.append("android_deploy.py") + script_dirs.extend(["deploy_lib/android", + "deploy_lib/android/recipes/PySide6", + "deploy_lib/android/recipes/shiboken6",]) + # For setting up setuptools entry points - for script in ("pyside_tool.py", "metaobjectdump.py", "project.py", "qml.py", - "qtpy2cpp.py", "deploy.py"): + for script in scripts: src = f"{{install_dir}}/bin/{script}" target = f"{{st_build_dir}}/{{st_package_name}}/scripts/{script}" copyfile(src, target, force=False, _vars=_vars) - for script_dir in ("qtpy2cpp_lib", "deploy_lib", "project"): + for script_dir in script_dirs: src = f"{{install_dir}}/bin/{script_dir}" target = f"{{st_build_dir}}/{{st_package_name}}/scripts/{script_dir}" # Exclude subdirectory tests diff --git a/create_wheels.py b/create_wheels.py index a56fb4957..5fd99d2aa 100644 --- a/create_wheels.py +++ b/create_wheels.py @@ -194,8 +194,17 @@ def wheel_shiboken_module() -> Tuple[SetupData, None]: def wheel_pyside6_essentials(packaged_qt_tools_path: Path) -> Tuple[SetupData, List[ModuleData]]: _pyside_tools = available_pyside_tools(packaged_qt_tools_path, package_for_wheels=True) - _console_scripts = [f"pyside6-{tool} = PySide6.scripts.pyside_tool:{tool}" - for tool in _pyside_tools] + + # replacing pyside6-android_deploy by pyside6-android-deploy for consistency + # Also, the tool should not exist in any other platform than Linux + _console_scripts = [] + if ("android_deploy" in _pyside_tools) and sys.platform.startswith("linux"): + _console_scripts = ["pyside6-android-deploy = PySide6.scripts.pyside_tool:android_deploy"] + _pyside_tools.remove("android_deploy") + + _console_scripts.extend([f"pyside6-{tool} = PySide6.scripts.pyside_tool:{tool}" + for tool in _pyside_tools]) + setup = SetupData( name="PySide6_Essentials", version=get_version_from_package("PySide6"), # we use 'PySide6' here diff --git a/requirements.txt b/requirements.txt index df4759377..d23611ef2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,3 +10,8 @@ nuitka==1.4.8 distro; sys_platform == 'linux' patchelf==0.15; sys_platform == 'linux' importlib-metadata; python_version < '3.8' +pkginfo; sys_platform == 'linux' +jinja2; sys_platform == 'linux' +buildozer==1.5.0; sys_platform == 'linux' +tqdm; sys_platform == 'linux' +gitpython; sys_platform == 'linux' diff --git a/sources/pyside-tools/CMakeLists.txt b/sources/pyside-tools/CMakeLists.txt index b4873459e..b6f7808e3 100644 --- a/sources/pyside-tools/CMakeLists.txt +++ b/sources/pyside-tools/CMakeLists.txt @@ -24,7 +24,8 @@ else() ${CMAKE_CURRENT_SOURCE_DIR}/project.py ${CMAKE_CURRENT_SOURCE_DIR}/qml.py ${CMAKE_CURRENT_SOURCE_DIR}/qtpy2cpp.py - ${CMAKE_CURRENT_SOURCE_DIR}/deploy.py) + ${CMAKE_CURRENT_SOURCE_DIR}/deploy.py + ${CMAKE_CURRENT_SOURCE_DIR}/android_deploy.py) set(directories ${CMAKE_CURRENT_SOURCE_DIR}/deploy_lib ${CMAKE_CURRENT_SOURCE_DIR}/project) diff --git a/sources/pyside-tools/android_deploy.py b/sources/pyside-tools/android_deploy.py new file mode 100644 index 000000000..b3d09cd94 --- /dev/null +++ b/sources/pyside-tools/android_deploy.py @@ -0,0 +1,233 @@ +# 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 argparse +import sys +import logging +import shutil +import traceback +from pathlib import Path +from textwrap import dedent + +from pkginfo import Wheel + +from deploy_lib import (setup_python, get_config, cleanup, install_python_dependencies, + config_option_exists, MAJOR_VERSION) +from deploy_lib.android import (create_recipe, extract_and_copy_jar, get_wheel_android_arch, + Buildozer, AndroidData, WIDGET_APPLICATION_MODULES, + QUICK_APPLICATION_MODULES) + + +""" pyside6-android-deploy deployment tool + + Deployment tool that uses buildozer (https://buildozer.readthedocs.io/en/latest/) and + python-for-android (https://python-for-android.readthedocs.io/en/latest/) to deploy PySide6 + applications to Android + + How does it work? + + Command: pyside6-android-deploy --wheel-pyside=<pyside_wheel_path> + --wheel-shiboken=<shiboken_wheel_path> + --ndk-path=<optional_ndk_path> + --sdk-path=<optional_sdk_path> + pyside6-android-deploy android -c /path/to/pysidedeploy.spec + + + Note: If --ndk-path and --sdk-path are not specified, the defaults from buildozer are used + + Prerequisities: Python main entrypoint file should be named "main.py" + + Platforms Supported: aarch64, armv7a, i686, x86_64 + + Supported Modules: Core, Gui, Widgets, Network, OpenGL, Qml, Quick, QuickControls2 + + Config file: + On the first run of the tool, it creates a config file called pysidedeploy.spec which + controls the various characteristic of the deployment. Users can simply change the value + in this config file to achieve different properties ie. change the application name, + deployment platform etc. + + Note: This file is used by both pyside6-deploy and pyside6-android-deploy +""" + + +def main(name: str = None, pyside_wheel: Path = None, shiboken_wheel: Path = None, ndk_path: Path = None, + sdk_path: Path = None, config_file: Path = None, init: bool = False, + loglevel=logging.WARNING, dry_run: bool = False, keep_deployment_files: bool = False, + force: bool = False): + + logging.basicConfig(level=loglevel) + main_file = Path.cwd() / "main.py" + generated_files_path = None + if not main_file.exists(): + print(dedent(""" + [DEPLOY] For android deployment to work, the main entrypoint Python file should be named + 'main.py' + """)) + return + + # check if ndk and sdk path given, else use default + if ndk_path and sdk_path: + logging.warning("[DEPLOY] May not work with custom Ndk and Sdk versions." + "Use the default by leaving out --ndk-path and --sdk-path cl" + "arguments") + + android_data = AndroidData(wheel_pyside=pyside_wheel, wheel_shiboken=shiboken_wheel, + ndk_path=ndk_path, sdk_path=sdk_path) + + if config_file and Path(config_file).exists(): + config_file = Path(config_file).resolve() + + python = setup_python(dry_run=dry_run, force=force, init=init) + config = get_config(python_exe=python.exe, dry_run=dry_run, config_file=config_file, + main_file=main_file, android_data=android_data, is_android=True) + + if config.project_dir != Path.cwd(): + raise RuntimeError("[DEPLOY] For Android deployment, pyside6-deploy should be run from" + f"{config.project_dir}") + + if not config.wheel_pyside and not config.wheel_shiboken: + raise RuntimeError(f"[DEPLOY] No PySide{MAJOR_VERSION} and Shiboken{MAJOR_VERSION} wheels" + "found") + + source_file = config.project_dir / config.source_file + generated_files_path = source_file.parent / "deployment" + cleanup(generated_files_path=generated_files_path, config=config, is_android=True) + + install_python_dependencies(config=config, python=python, init=init, + packages="android_packages", is_android=True) + + # set application name + if name: + config.title = name + + try: + # 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 config.recipes_exist(): + logging.info("[DEPLOY] Creating p4a recipes for PySide6 and shiboken6") + version = Wheel(config.wheel_pyside).version + create_recipe(version=version, component=f"PySide{MAJOR_VERSION}", + wheel_path=config.wheel_pyside, + generated_files_path=generated_files_path) + create_recipe(version=version, component=f"shiboken{MAJOR_VERSION}", + wheel_path=config.wheel_shiboken, + generated_files_path=generated_files_path) + config.recipe_dir = (generated_files_path / "recipes").resolve() + + # extract out and copy .jar files to {generated_files_path} + if not config.jars_dir or not Path(config.jars_dir).exists(): + logging.info("[DEPLOY] Extract and copy jar files from PySide6 wheel to " + f"{generated_files_path}") + extract_and_copy_jar(wheel_path=config.wheel_pyside, + generated_files_path=generated_files_path) + config.jars_dir = (generated_files_path / "jar" / "PySide6" / "jar").resolve() + + # check which modules are needed + # TODO: Optimize this based on the modules needed + # check if other modules not supported by Android used and raise error + if not config.modules: + config.modules = (QUICK_APPLICATION_MODULES if config.qml_files else + WIDGET_APPLICATION_MODULES) + + # find architecture from wheel name + if not config.arch: + arch = get_wheel_android_arch(wheel=config.wheel_pyside) + if not arch: + logging.exception("[DEPLOY] PySide wheel corrupted. Wheel name should end with" + "platform name") + raise + config.arch = arch + + # writing config file + if not dry_run: + config.update_config() + + if init: + # config file created above. Exiting. + logging.info(f"[DEPLOY]: Config file {config.config_file} created") + return + + # TODO: include qml files from pysidedeploy.spec rather than from extensions + # buildozer currently includes all the files with .qml extension + + # init buildozer + Buildozer.dry_run = dry_run + logging.info("[DEPLOY] Creating buildozer.spec file") + Buildozer.initialize(pysidedeploy_config=config) + + # run buildozer + logging.info("[DEPLOY] Running buildozer deployment") + Buildozer.create_executable(config.mode) + + # move buildozer build files to {generated_files_path} + if not dry_run: + buildozer_build_dir = config.project_dir / ".buildozer" + if not buildozer_build_dir.exists(): + logging.info(f"[DEPLOY] Unable to copy {buildozer_build_dir} to {generated_files_path}" + f"{buildozer_build_dir} does not exist") + logging.info(f"[DEPLOY] copy {buildozer_build_dir} to {generated_files_path}") + shutil.move(buildozer_build_dir, generated_files_path) + + logging.info(f"[DEPLOY] apk created in {config.exe_dir}") + except Exception: + print(f"Exception occurred: {traceback.format_exc()}") + finally: + if generated_files_path and config and not keep_deployment_files: + cleanup(generated_files_path=generated_files_path, config=config, is_android=True) + + logging.info("[DEPLOY] End") + + +if __name__ == "__main__": + parser = argparse.ArgumentParser( + description=dedent(f""" + This tool deploys PySide{MAJOR_VERSION} to Android platforms. + + Note: The main python entrypoint should be named main.py + """), + formatter_class=argparse.RawTextHelpFormatter, + ) + + parser.add_argument("-c", "--config-file", type=str, help="Path to the .spec config file") + + parser.add_argument( + "--init", action="store_true", + help="Create pysidedeploy.spec file, if it doesn't already exists") + + parser.add_argument( + "-v", "--verbose", help="run in verbose mode", action="store_const", + dest="loglevel", const=logging.INFO) + + parser.add_argument("--dry-run", action="store_true", help="show the commands to be run") + + parser.add_argument("--keep-deployment-files", action="store_true", + help="keep the generated deployment files generated") + + parser.add_argument("-f", "--force", action="store_true", help="force all input prompts") + + parser.add_argument("--name", type=str, help="Application name") + + parser.add_argument("--wheel-pyside", type=lambda p: Path(p).resolve(), + help=f"Path to PySide{MAJOR_VERSION} Android Wheel", + required=not config_option_exists()) + + parser.add_argument("--wheel-shiboken", type=lambda p: Path(p).resolve(), + help=f"Path to shiboken{MAJOR_VERSION} Android Wheel", + required=not config_option_exists()) + + parser.add_argument("--ndk-path", type=lambda p: Path(p).resolve(), + help=("Path to Android Ndk. If omitted, the default from buildozer is used") + , required="--sdk-path" in sys.argv) + + parser.add_argument("--sdk-path", type=lambda p: Path(p).resolve(), + help=("Path to Android Sdk. If omitted, the default from buildozer is used") + , required="--ndk-path" in sys.argv) + + args = parser.parse_args() + + main(args.name, args.wheel_pyside, args.wheel_shiboken, args.ndk_path, args.sdk_path, + args.config_file, args.init, args.loglevel, args.dry_run, args.keep_deployment_files, + args.force) diff --git a/sources/pyside-tools/android_deploy.pyproject b/sources/pyside-tools/android_deploy.pyproject new file mode 100644 index 000000000..f976cb5a6 --- /dev/null +++ b/sources/pyside-tools/android_deploy.pyproject @@ -0,0 +1,9 @@ +{ + "files": ["deploy.py", "deploy_lib/__init__.py", "deploy_lib/commands.py", "deploy_lib/config.py", + "deploy_lib/default.spec", "deploy_lib/python_helper.py", "deploy_lib/deploy_util.py", + "deploy_lib/android/recipes/PySide6/__init__.tmpl.py", + "deploy_lib/android/recipes/shiboken6/__init__.tmpl.py", + "deploy_lib/android/__init__.py", "deploy_lib/android/android_helper.py", + "deploy_lib/android/buildozer.py" + ] +} 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( diff --git a/sources/pyside-tools/pyside_tool.py b/sources/pyside-tools/pyside_tool.py index 5e5f857bc..7c9be28b1 100644 --- a/sources/pyside-tools/pyside_tool.py +++ b/sources/pyside-tools/pyside_tool.py @@ -199,5 +199,9 @@ def deploy(): pyside_script_wrapper("deploy.py") +def android_deploy(): + pyside_script_wrapper("android_deploy.py") + + if __name__ == "__main__": main() |
