aboutsummaryrefslogtreecommitdiffstats
path: root/sources/pyside-tools
diff options
context:
space:
mode:
Diffstat (limited to 'sources/pyside-tools')
-rw-r--r--sources/pyside-tools/CMakeLists.txt87
-rw-r--r--sources/pyside-tools/__init__.py0
-rw-r--r--sources/pyside-tools/android_deploy.py212
-rw-r--r--sources/pyside-tools/android_deploy.pyproject9
-rw-r--r--sources/pyside-tools/cmake/PySideAndroid.cmake52
-rw-r--r--sources/pyside-tools/cmake/PySideToolsHelpers.cmake37
-rw-r--r--sources/pyside-tools/cmake/PySideToolsSetup.cmake16
-rw-r--r--sources/pyside-tools/deploy.py203
-rw-r--r--sources/pyside-tools/deploy.pyproject8
-rw-r--r--sources/pyside-tools/deploy_lib/__init__.py59
-rw-r--r--sources/pyside-tools/deploy_lib/android/__init__.py16
-rw-r--r--sources/pyside-tools/deploy_lib/android/android_config.py446
-rw-r--r--sources/pyside-tools/deploy_lib/android/android_helper.py151
-rw-r--r--sources/pyside-tools/deploy_lib/android/buildozer.py147
-rw-r--r--sources/pyside-tools/deploy_lib/android/recipes/PySide6/__init__.tmpl.py64
-rw-r--r--sources/pyside-tools/deploy_lib/android/recipes/shiboken6/__init__.tmpl.py31
-rw-r--r--sources/pyside-tools/deploy_lib/commands.py60
-rw-r--r--sources/pyside-tools/deploy_lib/config.py480
-rw-r--r--sources/pyside-tools/deploy_lib/default.spec100
-rw-r--r--sources/pyside-tools/deploy_lib/dependency_util.py319
-rw-r--r--sources/pyside-tools/deploy_lib/deploy_util.py81
-rw-r--r--sources/pyside-tools/deploy_lib/nuitka_helper.py124
-rw-r--r--sources/pyside-tools/deploy_lib/pyside_icon.icnsbin0 -> 47064 bytes
-rw-r--r--sources/pyside-tools/deploy_lib/pyside_icon.icobin0 -> 48446 bytes
-rw-r--r--sources/pyside-tools/deploy_lib/pyside_icon.jpgbin0 -> 8157 bytes
-rw-r--r--sources/pyside-tools/deploy_lib/python_helper.py122
-rw-r--r--sources/pyside-tools/metaobjectdump.py452
-rw-r--r--sources/pyside-tools/project.py300
-rw-r--r--sources/pyside-tools/project.pyproject4
-rw-r--r--sources/pyside-tools/project/__init__.py46
-rw-r--r--sources/pyside-tools/project/newproject.py165
-rw-r--r--sources/pyside-tools/project/project_data.py244
-rw-r--r--sources/pyside-tools/project/utils.py107
-rw-r--r--sources/pyside-tools/pyside_tool.py243
-rw-r--r--sources/pyside-tools/qml.py246
-rw-r--r--sources/pyside-tools/qtpy2cpp.py62
-rw-r--r--sources/pyside-tools/qtpy2cpp.pyproject7
-rw-r--r--sources/pyside-tools/qtpy2cpp_lib/astdump.py111
-rw-r--r--sources/pyside-tools/qtpy2cpp_lib/formatter.py265
-rw-r--r--sources/pyside-tools/qtpy2cpp_lib/nodedump.py50
-rw-r--r--sources/pyside-tools/qtpy2cpp_lib/qt.py56
-rw-r--r--sources/pyside-tools/qtpy2cpp_lib/tests/baseline/basic_test.cpp62
-rw-r--r--sources/pyside-tools/qtpy2cpp_lib/tests/baseline/basic_test.py44
-rw-r--r--sources/pyside-tools/qtpy2cpp_lib/tests/test_qtpy2cpp.py54
-rw-r--r--sources/pyside-tools/qtpy2cpp_lib/tokenizer.py55
-rw-r--r--sources/pyside-tools/qtpy2cpp_lib/visitor.py442
-rw-r--r--sources/pyside-tools/requirements-android.txt2
47 files changed, 5841 insertions, 0 deletions
diff --git a/sources/pyside-tools/CMakeLists.txt b/sources/pyside-tools/CMakeLists.txt
new file mode 100644
index 000000000..e629ec570
--- /dev/null
+++ b/sources/pyside-tools/CMakeLists.txt
@@ -0,0 +1,87 @@
+# Copyright (C) 2023 The Qt Company Ltd.
+# SPDX-License-Identifier: BSD-3-Clause
+
+cmake_minimum_required(VERSION 3.18)
+project(pyside-tools)
+
+include(cmake/PySideToolsSetup.cmake)
+
+if(${CMAKE_SYSTEM_NAME} STREQUAL "Android")
+ # create Qt6AndroidBindings.jar
+ if (NOT DEFINED ANDROID_SDK_ROOT)
+ message(FATAL_ERROR "Please provide the location of the Android SDK directory via "
+ "your toolchain file")
+ endif()
+ if (NOT DEFINED ANDROID_PLATFORM)
+ message(FATAL_ERROR "Please provide the location of the Android Platform API level via "
+ "your toolchain file eg: android-31")
+ endif()
+ include(cmake/PySideAndroid.cmake)
+ create_and_install_qt_javabindings()
+else()
+ set(files ${CMAKE_CURRENT_SOURCE_DIR}/pyside_tool.py
+ ${CMAKE_CURRENT_SOURCE_DIR}/metaobjectdump.py
+ ${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}/android_deploy.py
+ ${CMAKE_CURRENT_SOURCE_DIR}/requirements-android.txt)
+
+ set(directories ${CMAKE_CURRENT_SOURCE_DIR}/deploy_lib
+ ${CMAKE_CURRENT_SOURCE_DIR}/project)
+
+ if(NOT NO_QT_TOOLS STREQUAL "yes")
+ set(TOOLS_PATH "${QT6_INSTALL_PREFIX}/${QT6_HOST_INFO_BINDIR}")
+ set(LIBEXEC_PATH "${QT6_INSTALL_PREFIX}/${QT6_HOST_INFO_LIBEXECDIR}")
+
+ list(APPEND files "${LIBEXEC_PATH}/uic${CMAKE_EXECUTABLE_SUFFIX}"
+ "${LIBEXEC_PATH}/rcc${CMAKE_EXECUTABLE_SUFFIX}"
+ "${LIBEXEC_PATH}/qmltyperegistrar${CMAKE_EXECUTABLE_SUFFIX}"
+ "${LIBEXEC_PATH}/qmlimportscanner${CMAKE_EXECUTABLE_SUFFIX}"
+ "${LIBEXEC_PATH}/qmlcachegen${CMAKE_EXECUTABLE_SUFFIX}"
+ "${TOOLS_PATH}/lrelease${CMAKE_EXECUTABLE_SUFFIX}"
+ "${TOOLS_PATH}/lupdate${CMAKE_EXECUTABLE_SUFFIX}"
+ "${TOOLS_PATH}/qmllint${CMAKE_EXECUTABLE_SUFFIX}"
+ "${TOOLS_PATH}/qmlformat${CMAKE_EXECUTABLE_SUFFIX}"
+ "${TOOLS_PATH}/qmlls${CMAKE_EXECUTABLE_SUFFIX}"
+ "${TOOLS_PATH}/qsb${CMAKE_EXECUTABLE_SUFFIX}"
+ "${TOOLS_PATH}/balsam${CMAKE_EXECUTABLE_SUFFIX}"
+ "${TOOLS_PATH}/balsamui${CMAKE_EXECUTABLE_SUFFIX}")
+
+ if (APPLE)
+ list(APPEND directories "${TOOLS_PATH}/Assistant.app"
+ "${TOOLS_PATH}/Designer.app"
+ "${TOOLS_PATH}/Linguist.app")
+ else()
+ list(APPEND files "${TOOLS_PATH}/assistant${CMAKE_EXECUTABLE_SUFFIX}"
+ "${TOOLS_PATH}/designer${CMAKE_EXECUTABLE_SUFFIX}"
+ "${TOOLS_PATH}/linguist${CMAKE_EXECUTABLE_SUFFIX}")
+ endif()
+ endif()
+
+ list(APPEND directories ${CMAKE_CURRENT_SOURCE_DIR}/qtpy2cpp_lib)
+
+ # pyside6-rcc, pyside6-uic, pyside6-designer, shiboken and pyside6-lupdate entrypoints
+ foreach(file ${files})
+ if(EXISTS ${file})
+ install(FILES "${file}"
+ DESTINATION bin
+ PERMISSIONS
+ OWNER_EXECUTE OWNER_WRITE OWNER_READ
+ GROUP_EXECUTE GROUP_READ
+ WORLD_EXECUTE WORLD_READ)
+ else()
+ message(WARNING "${file} does not exist. Hence, pyside6-${file} will not work")
+ endif()
+ endforeach()
+
+ foreach(directory ${directories})
+ install(DIRECTORY "${directory}"
+ DESTINATION bin
+ FILE_PERMISSIONS
+ OWNER_EXECUTE OWNER_WRITE OWNER_READ
+ GROUP_EXECUTE GROUP_READ
+ WORLD_EXECUTE WORLD_READ)
+ endforeach()
+endif()
diff --git a/sources/pyside-tools/__init__.py b/sources/pyside-tools/__init__.py
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/sources/pyside-tools/__init__.py
diff --git a/sources/pyside-tools/android_deploy.py b/sources/pyside-tools/android_deploy.py
new file mode 100644
index 000000000..75269d622
--- /dev/null
+++ b/sources/pyside-tools/android_deploy.py
@@ -0,0 +1,212 @@
+# 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 logging
+import shutil
+import traceback
+from pathlib import Path
+from textwrap import dedent
+
+from deploy_lib import (create_config_file, cleanup, config_option_exists, PythonExecutable,
+ MAJOR_VERSION, HELP_EXTRA_IGNORE_DIRS, HELP_EXTRA_MODULES)
+from deploy_lib.android import AndroidData, AndroidConfig
+from deploy_lib.android.buildozer import Buildozer
+
+
+""" 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 cache of the tool
+ `.pyside6_android_deploy` is checked in the user's HOME directory. If it is not found, the user
+ will have to manually download them.
+
+ Prerequisities: Python main entrypoint file should be named "main.py"
+
+ Platforms Supported: aarch64, armv7a, i686, x86_64
+
+ 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, extra_ignore_dirs: str = None, extra_modules_grouped: str = None):
+
+ logging.basicConfig(level=loglevel)
+
+ if extra_ignore_dirs:
+ extra_ignore_dirs = extra_ignore_dirs.split(",")
+
+ extra_modules = []
+ if extra_modules_grouped:
+ tmp_extra_modules = extra_modules_grouped.split(",")
+ for extra_module in tmp_extra_modules:
+ if extra_module.startswith("Qt"):
+ extra_modules.append(extra_module[2:])
+ else:
+ extra_modules.append(extra_module)
+
+ main_file = Path.cwd() / "main.py"
+ if not main_file.exists():
+ raise RuntimeError(("[DEPLOY] For Android deployment to work, the main"
+ " entrypoint Python file should be named 'main.py'"
+ " and it should be run from the application"
+ " directory"))
+
+ android_data = AndroidData(wheel_pyside=pyside_wheel, wheel_shiboken=shiboken_wheel,
+ ndk_path=ndk_path, sdk_path=sdk_path)
+
+ python = PythonExecutable(dry_run=dry_run, init=init, force=force)
+
+ config_file_exists = config_file and Path(config_file).exists()
+
+ if config_file_exists:
+ logging.info(f"[DEPLOY] Using existing config file {config_file}")
+ else:
+ config_file = create_config_file(dry_run=dry_run, config_file=config_file,
+ main_file=main_file)
+
+ config = AndroidConfig(config_file=config_file, source_file=main_file,
+ python_exe=python.exe, dry_run=dry_run, android_data=android_data,
+ existing_config_file=config_file_exists,
+ extra_ignore_dirs=extra_ignore_dirs)
+
+ if not config.wheel_pyside and not config.wheel_shiboken:
+ raise RuntimeError(f"[DEPLOY] No PySide{MAJOR_VERSION} and Shiboken{MAJOR_VERSION} wheels"
+ "found")
+
+ cleanup(config=config, is_android=True)
+
+ python.install_dependencies(config=config, packages="android_packages", is_android=True)
+
+ # set application name
+ if name:
+ config.title = name
+
+ try:
+ config.modules += list(set(extra_modules).difference(set(config.modules)))
+
+ # this cannot be done when config file is initialized because cleanup() removes it
+ # so this can only be done after the cleanup()
+ config.find_and_set_jars_dir()
+ config.verify_and_set_recipe_dir()
+
+ # 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)
+
+ # 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
+
+ # 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 "
+ f"{config.generated_files_path}. {buildozer_build_dir} does not exist")
+ logging.info(f"[DEPLOY] copy {buildozer_build_dir} to {config.generated_files_path}")
+ shutil.move(buildozer_build_dir, config.generated_files_path)
+
+ logging.info(f"[DEPLOY] apk created in {config.exe_dir}")
+ except Exception:
+ print(f"Exception occurred: {traceback.format_exc()}")
+ finally:
+ if config.generated_files_path and config and not keep_deployment_files:
+ cleanup(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=lambda p: Path(p).absolute(),
+ default=(Path.cwd() / "pysidedeploy.spec"),
+ 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 tool's cache at "
+ ".pyside6_android_deploy is checked to find the NDK")
+ )
+
+ parser.add_argument("--sdk-path", type=lambda p: Path(p).resolve(),
+ help=("Path to Android SDK. If omitted, the tool's cache at "
+ ".pyside6_android_deploy is checked to find the SDK. Otherwise "
+ "the default from buildozer is used.")
+ )
+
+ parser.add_argument("--extra-ignore-dirs", type=str, help=HELP_EXTRA_IGNORE_DIRS)
+
+ parser.add_argument("--extra-modules", type=str, help=HELP_EXTRA_MODULES)
+
+ 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, args.extra_ignore_dirs, args.extra_modules)
diff --git a/sources/pyside-tools/android_deploy.pyproject b/sources/pyside-tools/android_deploy.pyproject
new file mode 100644
index 000000000..bc6347243
--- /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", "deploy_lib/dependency_util.py"
+ ]
+}
diff --git a/sources/pyside-tools/cmake/PySideAndroid.cmake b/sources/pyside-tools/cmake/PySideAndroid.cmake
new file mode 100644
index 000000000..4b6260cce
--- /dev/null
+++ b/sources/pyside-tools/cmake/PySideAndroid.cmake
@@ -0,0 +1,52 @@
+# Copyright (C) 2023 The Qt Company Ltd.
+# SPDX-License-Identifier: BSD-3-Clause
+
+set(QT_MAJOR_VERSION 6)
+
+# Locate Java
+include(UseJava)
+# Find JDK 8.0
+find_package(Java 1.8 COMPONENTS Development REQUIRED)
+# Find QtJavaHelpers.java
+include("${QT6_INSTALL_PREFIX}/${QT6_INSTALL_LIBS}/cmake/Qt6/QtJavaHelpers.cmake")
+
+macro(create_and_install_qt_javabindings)
+
+ # create Qt6AndroidBindings.jar from the following {java_sources}
+ set(android_main_srcs "${QT6_INSTALL_PREFIX}/src/android/java/src/org/qtproject/qt/android/bindings")
+ set(java_sources
+ ${android_main_srcs}/QtActivity.java
+ ${android_main_srcs}/QtApplication.java
+ ${android_main_srcs}/QtService.java
+ )
+ # set android.jar from the sdk, for compiling the java files into .jar
+ set(sdk_jar_location "${ANDROID_SDK_ROOT}/platforms/${ANDROID_PLATFORM}/android.jar")
+ if (NOT EXISTS "${sdk_jar_location}")
+ message(FATAL_ERROR "Could not locate Android SDK jar for api '${api}'")
+ endif()
+
+ # this variable is accessed by qt_internal_add_jar
+ set(QT_ANDROID_JAR ${sdk_jar_location})
+
+ set(qt_jar_location "${QT6_INSTALL_PREFIX}/jar/Qt6Android.jar")
+ if (NOT EXISTS "${qt_jar_location}")
+ message(FATAL_ERROR "${qt_jar_location} does not exist. Qt6 installation maybe corrupted.")
+ endif()
+
+ # to be done
+ list(APPEND included_jars ${sdk_jar_location} ${qt_jar_location})
+
+ qt_internal_add_jar(Qt${QT_MAJOR_VERSION}AndroidBindings
+ INCLUDE_JARS ${included_jars}
+ SOURCES ${java_sources}
+ )
+
+ install_jar(Qt${QT_MAJOR_VERSION}AndroidBindings
+ DESTINATION "${CMAKE_INSTALL_PREFIX}/lib/jar"
+ COMPONENT Devel
+ )
+
+ # install other relevant Android jars from the Qt installation.
+ # All the jars would be later packaged together with the Android wheels
+ install(DIRECTORY ${QT6_INSTALL_PREFIX}/jar/ DESTINATION lib/jar)
+endmacro()
diff --git a/sources/pyside-tools/cmake/PySideToolsHelpers.cmake b/sources/pyside-tools/cmake/PySideToolsHelpers.cmake
new file mode 100644
index 000000000..9fb2ec3d0
--- /dev/null
+++ b/sources/pyside-tools/cmake/PySideToolsHelpers.cmake
@@ -0,0 +1,37 @@
+# Copyright (C) 2023 The Qt Company Ltd.
+# SPDX-License-Identifier: BSD-3-Clause
+
+function(pyside_tools_internal_detect_if_cross_building)
+ if(CMAKE_CROSSCOMPILING OR QFP_SHIBOKEN_HOST_PATH)
+ set(is_cross_build TRUE)
+ else()
+ set(is_cross_build FALSE)
+ endif()
+ set(PYSIDE_TOOLS_IS_CROSS_BUILD "${is_cross_build}" PARENT_SCOPE)
+ message(STATUS "PYSIDE_TOOLS_IS_CROSS_BUILD: ${PYSIDE_TOOLS_IS_CROSS_BUILD}")
+endfunction()
+
+function(pyside_tools_internal_set_up_extra_dependency_paths)
+ set(extra_root_path_vars
+ QFP_QT_TARGET_PATH
+ )
+ foreach(root_path IN LISTS extra_root_path_vars)
+ set(new_root_path_value "${${root_path}}")
+ if(new_root_path_value)
+ set(new_prefix_path "${CMAKE_PREFIX_PATH}")
+ list(PREPEND new_prefix_path "${new_root_path_value}/lib/cmake")
+ set(CMAKE_PREFIX_PATH "${new_prefix_path}")
+ set(CMAKE_PREFIX_PATH "${new_prefix_path}" PARENT_SCOPE)
+
+ # Need to adjust the prefix and root paths so that find_package(Qt) and other 3rd
+ # party packages are found successfully when they are located outside of the
+ # default sysroot (whatever that maybe for the target platform).
+ if(PYSIDE_TOOLS_IS_CROSS_BUILD)
+ set(new_root_path "${CMAKE_FIND_ROOT_PATH}")
+ list(PREPEND new_root_path "${new_root_path_value}")
+ set(CMAKE_FIND_ROOT_PATH "${new_root_path}")
+ set(CMAKE_FIND_ROOT_PATH "${new_root_path}" PARENT_SCOPE)
+ endif()
+ endif()
+ endforeach()
+endfunction()
diff --git a/sources/pyside-tools/cmake/PySideToolsSetup.cmake b/sources/pyside-tools/cmake/PySideToolsSetup.cmake
new file mode 100644
index 000000000..93b39460d
--- /dev/null
+++ b/sources/pyside-tools/cmake/PySideToolsSetup.cmake
@@ -0,0 +1,16 @@
+# Copyright (C) 2023 The Qt Company Ltd.
+# SPDX-License-Identifier: BSD-3-Clause
+
+list(APPEND CMAKE_MODULE_PATH "${CMAKE_CURRENT_LIST_DIR}")
+
+include(PySideToolsHelpers)
+
+pyside_tools_internal_detect_if_cross_building()
+pyside_tools_internal_set_up_extra_dependency_paths()
+
+find_package(Qt6 REQUIRED COMPONENTS Core HostInfo)
+
+# Don't display "up-to-date / install" messages when installing, to reduce visual clutter.
+if (QUIET_BUILD)
+ set(CMAKE_INSTALL_MESSAGE NEVER)
+endif()
diff --git a/sources/pyside-tools/deploy.py b/sources/pyside-tools/deploy.py
new file mode 100644
index 000000000..bab5aa0de
--- /dev/null
+++ b/sources/pyside-tools/deploy.py
@@ -0,0 +1,203 @@
+# 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
+
+""" pyside6-deploy deployment tool
+
+ Deployment tool that uses Nuitka to deploy PySide6 applications to various desktop (Windows,
+ Linux, macOS) platforms.
+
+ How does it work?
+
+ Command: pyside6-deploy path/to/main_file
+ pyside6-deploy (incase main file is called main.py)
+ pyside6-deploy -c /path/to/config_file
+
+ Platforms supported: Linux, Windows, macOS
+ Module binary inclusion:
+ 1. for non-QML cases, only required modules are included
+ 2. for QML cases, all modules are included because of all QML plugins getting included
+ with nuitka
+
+ 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
+
+"""
+
+import argparse
+import logging
+import traceback
+from pathlib import Path
+from textwrap import dedent
+
+from deploy_lib import (MAJOR_VERSION, DesktopConfig, cleanup, config_option_exists,
+ finalize, create_config_file, PythonExecutable, Nuitka,
+ HELP_EXTRA_MODULES, HELP_EXTRA_IGNORE_DIRS)
+
+
+TOOL_DESCRIPTION = dedent(f"""
+ This tool deploys PySide{MAJOR_VERSION} to desktop (Windows, Linux,
+ macOS) platforms. The following types of executables are produced as per
+ the platform:
+
+ Windows = .exe
+ macOS = .app
+ Linux = .bin
+ """)
+
+HELP_MODE = dedent("""
+ The mode in which the application is deployed. The options are: onefile,
+ standalone. The default value is onefile.
+
+ This options translates to the mode Nuitka uses to create the executable.
+
+ macOS by default uses the --standalone option.
+ """)
+
+
+def main(main_file: Path = None, name: str = None, config_file: Path = None, init: bool = False,
+ loglevel=logging.WARNING, dry_run: bool = False, keep_deployment_files: bool = False,
+ force: bool = False, extra_ignore_dirs: str = None, extra_modules_grouped: str = None,
+ mode: bool = False):
+
+ logging.basicConfig(level=loglevel)
+ if config_file and not config_file.exists() and not main_file.exists():
+ raise RuntimeError(dedent("""
+ Directory does not contain main.py file.
+ Please specify the main python entrypoint file or the config file.
+ Run "pyside6-deploy desktop --help" to see info about cli options.
+
+ pyside6-deploy exiting..."""))
+
+ # Nuitka command to run
+ command_str = None
+ config = None
+ logging.info("[DEPLOY] Start")
+
+ if extra_ignore_dirs:
+ extra_ignore_dirs = extra_ignore_dirs.split(",")
+
+ extra_modules = []
+ if extra_modules_grouped:
+ tmp_extra_modules = extra_modules_grouped.split(",")
+ for extra_module in tmp_extra_modules:
+ if extra_module.startswith("Qt"):
+ extra_modules.append(extra_module[2:])
+ else:
+ extra_modules.append(extra_module)
+
+ python = PythonExecutable(dry_run=dry_run, init=init, force=force)
+ config_file_exists = config_file and Path(config_file).exists()
+
+ if config_file_exists:
+ logging.info(f"[DEPLOY] Using existing config file {config_file}")
+ else:
+ config_file = create_config_file(dry_run=dry_run, config_file=config_file,
+ main_file=main_file)
+
+ config = DesktopConfig(config_file=config_file, source_file=main_file, python_exe=python.exe,
+ dry_run=dry_run, existing_config_file=config_file_exists,
+ extra_ignore_dirs=extra_ignore_dirs, mode=mode)
+
+ # set application name
+ if name:
+ config.title = name
+
+ cleanup(config=config)
+
+ python.install_dependencies(config=config, packages="packages")
+
+ # required by Nuitka for pyenv Python
+ add_arg = " --static-libpython=no"
+ if python.is_pyenv_python() and add_arg not in config.extra_args:
+ config.extra_args += add_arg
+
+ config.modules += list(set(extra_modules).difference(set(config.modules)))
+
+ # writing config file
+ # in the case of --dry-run, we use default.spec as reference. Do not save the changes
+ # for --dry-run
+ if not dry_run:
+ config.update_config()
+
+ if config.qml_files:
+ logging.info(f"[DEPLOY] Included QML files: {config.qml_files}")
+
+ if init:
+ # config file created above. Exiting.
+ logging.info(f"[DEPLOY]: Config file {config.config_file} created")
+ return
+
+ try:
+ # create executable
+ if not dry_run:
+ logging.info("[DEPLOY] Deploying application")
+
+ nuitka = Nuitka(nuitka=[python.exe, "-m", "nuitka"])
+ command_str = nuitka.create_executable(source_file=config.source_file,
+ extra_args=config.extra_args,
+ qml_files=config.qml_files,
+ qt_plugins=config.qt_plugins,
+ excluded_qml_plugins=config.excluded_qml_plugins,
+ icon=config.icon,
+ dry_run=dry_run,
+ permissions=config.permissions,
+ mode=config.mode)
+ except Exception:
+ print(f"[DEPLOY] Exception occurred: {traceback.format_exc()}")
+ finally:
+ if config.generated_files_path and config:
+ finalize(config=config)
+ if not keep_deployment_files:
+ cleanup(config=config)
+
+ logging.info("[DEPLOY] End")
+ return command_str
+
+
+if __name__ == "__main__":
+ parser = argparse.ArgumentParser(description=TOOL_DESCRIPTION)
+
+ parser.add_argument("-c", "--config-file", type=lambda p: Path(p).absolute(),
+ default=(Path.cwd() / "pysidedeploy.spec"),
+ help="Path to the .spec config file")
+
+ parser.add_argument(
+ type=lambda p: Path(p).absolute(),
+ help="Path to main python file", nargs="?", dest="main_file",
+ default=None if config_option_exists() else Path.cwd() / "main.py")
+
+ 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("--extra-ignore-dirs", type=str, help=HELP_EXTRA_IGNORE_DIRS)
+
+ parser.add_argument("--extra-modules", type=str, help=HELP_EXTRA_MODULES)
+
+ parser.add_argument("--mode", choices=["onefile", "standalone"], default="desktop",
+ help=HELP_MODE)
+
+ args = parser.parse_args()
+
+ main(args.main_file, args.name, args.config_file, args.init, args.loglevel, args.dry_run,
+ args.keep_deployment_files, args.force, args.extra_ignore_dirs, args.extra_modules,
+ args.mode)
diff --git a/sources/pyside-tools/deploy.pyproject b/sources/pyside-tools/deploy.pyproject
new file mode 100644
index 000000000..0e6ca8251
--- /dev/null
+++ b/sources/pyside-tools/deploy.pyproject
@@ -0,0 +1,8 @@
+{
+ "files": ["deploy.py", "deploy_lib/__init__.py", "deploy_lib/commands.py", "deploy_lib/config.py",
+ "deploy_lib/default.spec", "deploy_lib/nuitka_helper.py", "deploy_lib/pyside_icon.ico",
+ "deploy_lib/pyside_icon.icns","deploy_lib/pyside_icon.jpg",
+ "deploy_lib/python_helper.py", "deploy_lib/deploy_util.py",
+ "deploy_lib/dependency_util.py"
+ ]
+}
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..5d5070bef
--- /dev/null
+++ b/sources/pyside-tools/deploy_lib/config.py
@@ -0,0 +1,480 @@
+# 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 enum import Enum
+
+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
+ """
+ class NuitkaMode(Enum):
+ ONEFILE = "onefile"
+ STANDALONE = "standalone"
+
+ 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,
+ mode: str = "onefile"):
+ 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()
+
+ self._mode = self.NuitkaMode.ONEFILE
+ if self.get_value("nuitka", "mode") == self.NuitkaMode.STANDALONE.value:
+ self._mode = self.NuitkaMode.STANDALONE
+ elif mode == self.NuitkaMode.STANDALONE.value:
+ self.mode = self.NuitkaMode.STANDALONE
+
+ @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))
+
+ @property
+ def mode(self):
+ return self._mode
+
+ @mode.setter
+ def mode(self, mode: NuitkaMode):
+ self._mode = mode
+ self.set_value("nuitka", "mode", mode.value)
+
+ 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..2e28b2f7c
--- /dev/null
+++ b/sources/pyside-tools/deploy_lib/default.spec
@@ -0,0 +1,100 @@
+[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 =
+
+# mode of using Nuitka. Accepts standalone or onefile. Default is onefile.
+mode = onefile
+
+# (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..1e0e2712a
--- /dev/null
+++ b/sources/pyside-tools/deploy_lib/deploy_util.py
@@ -0,0 +1,81 @@
+# 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, DesktopConfig
+
+
+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: DesktopConfig):
+ """
+ Copy the executable into the final location
+ For Android deployment, this is done through buildozer
+ """
+ dist_format = EXE_FORMAT
+ if config.mode == DesktopConfig.NuitkaMode.STANDALONE and sys.platform != "darwin":
+ dist_format = ".dist"
+
+ generated_exec_path = config.generated_files_path / (config.source_file.stem + dist_format)
+ if generated_exec_path.exists() and config.exe_dir:
+ if sys.platform == "darwin" or config.mode == DesktopConfig.NuitkaMode.STANDALONE:
+ shutil.copytree(generated_exec_path, config.exe_dir / (config.title + dist_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 + dist_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..5d0e9032f
--- /dev/null
+++ b/sources/pyside-tools/deploy_lib/nuitka_helper.py
@@ -0,0 +1,124 @@
+# 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
+
+# enables to use typehints for classes that has not been defined yet or imported
+# used for resolving circular imports
+from __future__ import annotations
+import logging
+import os
+import sys
+from pathlib import Path
+from typing import List
+
+from . import MAJOR_VERSION, run_command
+from .config import DesktopConfig
+
+
+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],
+ mode: DesktopConfig.NuitkaMode):
+ qt_plugins = [plugin for plugin in qt_plugins if plugin not in self.qt_plugins_to_ignore]
+ extra_args = extra_args.split()
+
+ # macOS uses the --standalone option by default to create an app bundle
+ 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(f"--{mode.value}")
+
+ qml_args = []
+ if qml_files:
+ # This will generate options for each file using:
+ # --include-data-files=ABSOLUTE_PATH_TO_FILE=RELATIVE_PATH_TO ROOT
+ # for each file. This will preserve the directory structure of QML resources.
+ qml_args.extend(
+ [f"--include-data-files={qml_file.resolve()}="
+ f"./{qml_file.resolve().relative_to(source_file.parent)}"
+ for qml_file in qml_files]
+ )
+ # add qml plugin. The `qml`` plugin name is not present in the module json files shipped
+ # with Qt and hence not in `qt_plugins``. However, Nuitka uses the 'qml' plugin name to
+ # include the necessary qml plugins. There we have to add it explicitly for a qml
+ # application
+ qt_plugins.append("qml")
+
+ if excluded_qml_plugins:
+ prefix = "lib" if sys.platform != "win32" else ""
+ for plugin in excluded_qml_plugins:
+ dll_name = plugin.replace("Qt", f"Qt{MAJOR_VERSION}")
+ qml_args.append(f"--noinclude-dlls={prefix}{dll_name}*")
+
+ # Exclude .qen json files from QtQuickEffectMaker
+ # These files are not relevant for PySide6 applications
+ qml_args.append("--noinclude-dlls=*/qml/QtQuickEffectMaker/*")
+
+ # Exclude files that cannot be processed by Nuitka
+ for file in self.files_to_ignore:
+ extra_args.append(f"--noinclude-dlls=*{file}")
+
+ output_dir = source_file.parent / "deployment"
+ if not dry_run:
+ output_dir.mkdir(parents=True, exist_ok=True)
+ logging.info("[DEPLOY] Running Nuitka")
+ command = self.nuitka + [
+ os.fspath(source_file),
+ "--follow-imports",
+ "--enable-plugin=pyside6",
+ f"--output-dir={output_dir}",
+ ]
+
+ command.extend(extra_args + qml_args)
+ command.append(f"{self.__class__.icon_option()}={icon}")
+ if qt_plugins:
+ # sort qt_plugins so that the result is definitive when testing
+ qt_plugins.sort()
+ qt_plugins_str = ",".join(qt_plugins)
+ command.append(f"--include-qt-plugins={qt_plugins_str}")
+
+ command_str, _ = run_command(command=command, dry_run=dry_run)
+ return command_str
diff --git a/sources/pyside-tools/deploy_lib/pyside_icon.icns b/sources/pyside-tools/deploy_lib/pyside_icon.icns
new file mode 100644
index 000000000..a6eb02bb0
--- /dev/null
+++ b/sources/pyside-tools/deploy_lib/pyside_icon.icns
Binary files differ
diff --git a/sources/pyside-tools/deploy_lib/pyside_icon.ico b/sources/pyside-tools/deploy_lib/pyside_icon.ico
new file mode 100644
index 000000000..332a3a568
--- /dev/null
+++ b/sources/pyside-tools/deploy_lib/pyside_icon.ico
Binary files differ
diff --git a/sources/pyside-tools/deploy_lib/pyside_icon.jpg b/sources/pyside-tools/deploy_lib/pyside_icon.jpg
new file mode 100644
index 000000000..647c42c71
--- /dev/null
+++ b/sources/pyside-tools/deploy_lib/pyside_icon.jpg
Binary files differ
diff --git a/sources/pyside-tools/deploy_lib/python_helper.py b/sources/pyside-tools/deploy_lib/python_helper.py
new file mode 100644
index 000000000..7cbf323ed
--- /dev/null
+++ b/sources/pyside-tools/deploy_lib/python_helper.py
@@ -0,0 +1,122 @@
+# Copyright (C) 2022 The Qt Company Ltd.
+# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR LGPL-3.0-only OR GPL-2.0-only OR GPL-3.0-only
+
+import logging
+import os
+import sys
+
+from importlib import util
+from importlib.metadata import version
+from pathlib import Path
+
+from . import Config, run_command
+
+
+class PythonExecutable:
+ """
+ Wrapper class around Python executable
+ """
+
+ def __init__(self, python_path: Path = None, dry_run: bool = False, init: bool = False,
+ force: bool = False):
+
+ self.dry_run = dry_run
+ self.init = init
+ if not python_path:
+ response = "yes"
+ # checking if inside virtual environment
+ if not self.is_venv() and not force and not self.dry_run and not self.init:
+ response = input(("You are not using a virtual environment. pyside6-deploy needs "
+ "to install a few Python packages for deployment to work "
+ "seamlessly. \n Proceed? [Y/n]"))
+
+ if response.lower() in ["no", "n"]:
+ print("[DEPLOY] Exiting ...")
+ sys.exit(0)
+
+ self.exe = Path(sys.executable)
+ else:
+ self.exe = python_path
+
+ logging.info(f"[DEPLOY] Using Python at {str(self.exe)}")
+
+ @property
+ def exe(self):
+ return Path(self._exe)
+
+ @exe.setter
+ def exe(self, exe):
+ self._exe = exe
+
+ @staticmethod
+ def is_venv():
+ venv = os.environ.get("VIRTUAL_ENV")
+ return True if venv else False
+
+ def is_pyenv_python(self):
+ pyenv_root = os.environ.get("PYENV_ROOT")
+
+ if pyenv_root:
+ resolved_exe = self.exe.resolve()
+ if str(resolved_exe).startswith(pyenv_root):
+ return True
+
+ return False
+
+ def install(self, packages: list = None):
+ _, installed_packages = run_command(command=[str(self.exe), "-m", "pip", "freeze"],
+ dry_run=False, fetch_output=True)
+ installed_packages = [p.decode().split('==')[0] for p in installed_packages.split()]
+ for package in packages:
+ package_info = package.split('==')
+ package_components_len = len(package_info)
+ package_name, package_version = None, None
+ if package_components_len == 1:
+ package_name = package_info[0]
+ elif package_components_len == 2:
+ package_name = package_info[0]
+ package_version = package_info[1]
+ else:
+ raise ValueError(f"{package} should be of the format 'package_name'=='version'")
+ if (package_name not in installed_packages) and (not self.is_installed(package_name)):
+ logging.info(f"[DEPLOY] Installing package: {package}")
+ run_command(
+ command=[self.exe, "-m", "pip", "install", package],
+ dry_run=self.dry_run,
+ )
+ elif package_version:
+ installed_version = version(package_name)
+ if package_version != installed_version:
+ logging.info(f"[DEPLOY] Installing package: {package_name}"
+ f"version: {package_version}")
+ run_command(
+ command=[self.exe, "-m", "pip", "install", "--force", package],
+ dry_run=self.dry_run,
+ )
+ else:
+ logging.info(f"[DEPLOY] package: {package_name}=={package_version}"
+ " already installed")
+ else:
+ logging.info(f"[DEPLOY] package: {package_name} already installed")
+
+ def is_installed(self, package):
+ return bool(util.find_spec(package))
+
+ def install_dependencies(self, config: Config, packages: str, is_android: bool = False):
+ """
+ Installs the python package dependencies for the target deployment platform
+ """
+ packages = config.get_value("python", packages).split(",")
+ if not self.init:
+ # install packages needed for deployment
+ logging.info("[DEPLOY] Installing dependencies")
+ self.install(packages=packages)
+ # nuitka requires patchelf to make patchelf rpath changes for some Qt files
+ if sys.platform.startswith("linux") and not is_android:
+ self.install(packages=["patchelf"])
+ elif is_android:
+ # install only buildozer
+ logging.info("[DEPLOY] Installing buildozer")
+ buildozer_package_with_version = ([package for package in packages
+ if package.startswith("buildozer")])
+ self.install(packages=list(buildozer_package_with_version))
diff --git a/sources/pyside-tools/metaobjectdump.py b/sources/pyside-tools/metaobjectdump.py
new file mode 100644
index 000000000..0970f9974
--- /dev/null
+++ b/sources/pyside-tools/metaobjectdump.py
@@ -0,0 +1,452 @@
+# 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 ast
+import json
+import os
+import sys
+import tokenize
+from argparse import ArgumentParser, RawTextHelpFormatter
+from pathlib import Path
+from typing import Dict, List, Optional, Set, Tuple, Union
+
+
+DESCRIPTION = """Parses Python source code to create QObject metatype
+information in JSON format for qmltyperegistrar."""
+
+
+REVISION = 68
+
+
+CPP_TYPE_MAPPING = {"str": "QString"}
+
+
+QML_IMPORT_NAME = "QML_IMPORT_NAME"
+QML_IMPORT_MAJOR_VERSION = "QML_IMPORT_MAJOR_VERSION"
+QML_IMPORT_MINOR_VERSION = "QML_IMPORT_MINOR_VERSION"
+QT_MODULES = "QT_MODULES"
+
+
+ITEM_MODELS = ["QAbstractListModel", "QAbstractProxyModel",
+ "QAbstractTableModel", "QConcatenateTablesProxyModel",
+ "QFileSystemModel", "QIdentityProxyModel", "QPdfBookmarkModel",
+ "QPdfSearchModel", "QSortFilterProxyModel", "QSqlQueryModel",
+ "QStandardItemModel", "QStringListModel", "QTransposeProxyModel",
+ "QWebEngineHistoryModel"]
+
+
+QOBJECT_DERIVED = ["QObject", "QQuickItem", "QQuickPaintedItem"] + ITEM_MODELS
+
+
+AstDecorator = Union[ast.Name, ast.Call]
+AstPySideTypeSpec = Union[ast.Name, ast.Constant]
+
+
+ClassList = List[dict]
+
+
+PropertyEntry = Dict[str, Union[str, int, bool]]
+
+Argument = Dict[str, str]
+Arguments = List[Argument]
+Signal = Dict[str, Union[str, Arguments]]
+Slot = Dict[str, Union[str, Arguments]]
+
+
+def _decorator(name: str, value: str) -> Dict[str, str]:
+ """Create a QML decorator JSON entry"""
+ return {"name": name, "value": value}
+
+
+def _attribute(node: ast.Attribute) -> Tuple[str, str]:
+ """Split an attribute."""
+ return node.value.id, node.attr
+
+
+def _name(node: Union[ast.Name, ast.Attribute]) -> str:
+ """Return the name of something that is either an attribute or a name,
+ such as base classes or call.func"""
+ if isinstance(node, ast.Attribute):
+ qualifier, name = _attribute(node)
+ return f"{qualifier}.{node.attr}"
+ return node.id
+
+
+def _func_name(node: ast.Call) -> str:
+ return _name(node.func)
+
+
+def _python_to_cpp_type(type: str) -> str:
+ """Python to C++ type"""
+ c = CPP_TYPE_MAPPING.get(type)
+ return c if c else type
+
+
+def _parse_property_kwargs(keywords: List[ast.keyword], prop: PropertyEntry):
+ """Parse keyword arguments of @Property"""
+ for k in keywords:
+ if k.arg == "notify":
+ prop["notify"] = _name(k.value)
+
+
+def _parse_assignment(node: ast.Assign) -> Tuple[Optional[str], Optional[ast.AST]]:
+ """Parse an assignment and return a tuple of name, value."""
+ if len(node.targets) == 1 and isinstance(node.targets[0], ast.Name):
+ var_name = node.targets[0].id
+ return (var_name, node.value)
+ return (None, None)
+
+
+def _parse_pyside_type(type_spec: AstPySideTypeSpec) -> str:
+ """Parse type specification of a Slot/Property decorator. Usually a type,
+ but can also be a string constant with a C++ type name."""
+ if isinstance(type_spec, ast.Constant):
+ return type_spec.value
+ return _python_to_cpp_type(_name(type_spec))
+
+
+def _parse_call_args(call: ast.Call):
+ """Parse arguments of a Signal call/Slot decorator (type list)."""
+ result: Arguments = []
+ for n, arg in enumerate(call.args):
+ par_name = f"a{n+1}"
+ par_type = _parse_pyside_type(arg)
+ result.append({"name": par_name, "type": par_type})
+ return result
+
+
+def _parse_slot(func_name: str, call: ast.Call) -> Slot:
+ """Parse a 'Slot' decorator."""
+ return_type = "void"
+ for kwarg in call.keywords:
+ if kwarg.arg == "result":
+ return_type = _python_to_cpp_type(_name(kwarg.value))
+ break
+ return {"access": "public", "name": func_name,
+ "arguments": _parse_call_args(call),
+ "returnType": return_type}
+
+
+class VisitorContext:
+ """Stores a list of QObject-derived classes encountered in order to find
+ out which classes inherit QObject."""
+
+ def __init__(self):
+ self.qobject_derived = QOBJECT_DERIVED
+
+
+class MetaObjectDumpVisitor(ast.NodeVisitor):
+ """AST visitor for parsing sources and creating the data structure for
+ JSON."""
+
+ def __init__(self, context: VisitorContext):
+ super().__init__()
+ self._context = context
+ self._json_class_list: ClassList = []
+ # Property by name, which will be turned into the JSON List later
+ self._properties: List[PropertyEntry] = []
+ self._signals: List[Signal] = []
+ self._within_class: bool = False
+ self._qt_modules: Set[str] = set()
+ self._qml_import_name = ""
+ self._qml_import_major_version = 0
+ self._qml_import_minor_version = 0
+
+ def json_class_list(self) -> ClassList:
+ return self._json_class_list
+
+ def qml_import_name(self) -> str:
+ return self._qml_import_name
+
+ def qml_import_version(self) -> Tuple[int, int]:
+ return (self._qml_import_major_version, self._qml_import_minor_version)
+
+ def qt_modules(self):
+ return sorted(self._qt_modules)
+
+ @staticmethod
+ def create_ast(filename: Path) -> ast.Module:
+ """Create an Abstract Syntax Tree on which a visitor can be run"""
+ node = None
+ with tokenize.open(filename) as file:
+ node = ast.parse(file.read(), mode="exec")
+ return node
+
+ def visit_Assign(self, node: ast.Assign):
+ """Parse the global constants for QML-relevant values"""
+ var_name, value_node = _parse_assignment(node)
+ if not var_name or not isinstance(value_node, ast.Constant):
+ return
+ value = value_node.value
+ if var_name == QML_IMPORT_NAME:
+ self._qml_import_name = value
+ elif var_name == QML_IMPORT_MAJOR_VERSION:
+ self._qml_import_major_version = value
+ elif var_name == QML_IMPORT_MINOR_VERSION:
+ self._qml_import_minor_version = value
+
+ def visit_ClassDef(self, node: ast.Module):
+ """Visit a class definition"""
+ self._properties = []
+ self._signals = []
+ self._slots = []
+ self._within_class = True
+ qualified_name = node.name
+ last_dot = qualified_name.rfind('.')
+ name = (qualified_name[last_dot + 1:] if last_dot != -1
+ else qualified_name)
+
+ data = {"className": name,
+ "qualifiedClassName": qualified_name}
+
+ q_object = False
+ bases = []
+ for b in node.bases:
+ # PYSIDE-2202: catch weird constructs like "class C(type(Base)):"
+ if isinstance(b, ast.Name):
+ base_name = _name(b)
+ if base_name in self._context.qobject_derived:
+ q_object = True
+ self._context.qobject_derived.append(name)
+ base_dict = {"access": "public", "name": base_name}
+ bases.append(base_dict)
+
+ data["object"] = q_object
+ if bases:
+ data["superClasses"] = bases
+
+ class_decorators: List[dict] = []
+ for d in node.decorator_list:
+ self._parse_class_decorator(d, class_decorators)
+
+ if class_decorators:
+ data["classInfos"] = class_decorators
+
+ for b in node.body:
+ if isinstance(b, ast.Assign):
+ self._parse_class_variable(b)
+ else:
+ self.visit(b)
+
+ if self._properties:
+ data["properties"] = self._properties
+
+ if self._signals:
+ data["signals"] = self._signals
+
+ if self._slots:
+ data["slots"] = self._slots
+
+ self._json_class_list.append(data)
+
+ self._within_class = False
+
+ def visit_FunctionDef(self, node):
+ if self._within_class:
+ for d in node.decorator_list:
+ self._parse_function_decorator(node.name, d)
+
+ def _parse_class_decorator(self, node: AstDecorator,
+ class_decorators: List[dict]):
+ """Parse ClassInfo decorators."""
+ if isinstance(node, ast.Call):
+ name = _func_name(node)
+ if name == "QmlUncreatable":
+ class_decorators.append(_decorator("QML.Creatable", "false"))
+ if node.args:
+ reason = node.args[0].value
+ if isinstance(reason, str):
+ d = _decorator("QML.UncreatableReason", reason)
+ class_decorators.append(d)
+ elif name == "QmlAttached" and len(node.args) == 1:
+ d = _decorator("QML.Attached", node.args[0].id)
+ class_decorators.append(d)
+ elif name == "QmlExtended" and len(node.args) == 1:
+ d = _decorator("QML.Extended", node.args[0].id)
+ class_decorators.append(d)
+ elif name == "ClassInfo" and node.keywords:
+ kw = node.keywords[0]
+ class_decorators.append(_decorator(kw.arg, kw.value.value))
+ elif name == "QmlForeign" and len(node.args) == 1:
+ d = _decorator("QML.Foreign", node.args[0].id)
+ class_decorators.append(d)
+ elif name == "QmlNamedElement" and node.args:
+ name = node.args[0].value
+ class_decorators.append(_decorator("QML.Element", name))
+ elif name.startswith('Q'):
+ print('Unknown decorator with parameters:', name,
+ file=sys.stderr)
+ return
+
+ if isinstance(node, ast.Name):
+ name = node.id
+ if name == "QmlElement":
+ class_decorators.append(_decorator("QML.Element", "auto"))
+ elif name == "QmlSingleton":
+ class_decorators.append(_decorator("QML.Singleton", "true"))
+ elif name == "QmlAnonymous":
+ class_decorators.append(_decorator("QML.Element", "anonymous"))
+ elif name.startswith('Q'):
+ print('Unknown decorator:', name, file=sys.stderr)
+ return
+
+ def _index_of_property(self, name: str) -> int:
+ """Search a property by name"""
+ for i in range(len(self._properties)):
+ if self._properties[i]["name"] == name:
+ return i
+ return -1
+
+ def _create_property_entry(self, name: str, type: str,
+ getter: Optional[str] = None) -> PropertyEntry:
+ """Create a property JSON entry."""
+ result: PropertyEntry = {"name": name, "type": type,
+ "index": len(self._properties)}
+ if getter:
+ result["read"] = getter
+ return result
+
+ def _parse_function_decorator(self, func_name: str, node: AstDecorator):
+ """Parse function decorators."""
+ if isinstance(node, ast.Attribute):
+ name = node.value.id
+ value = node.attr
+ if value == "setter": # Property setter
+ idx = self._index_of_property(name)
+ if idx != -1:
+ self._properties[idx]["write"] = func_name
+ return
+
+ if isinstance(node, ast.Call):
+ name = _name(node.func)
+ if name == "Property": # Property getter
+ if node.args: # 1st is type/type string
+ type = _parse_pyside_type(node.args[0])
+ prop = self._create_property_entry(func_name, type,
+ func_name)
+ _parse_property_kwargs(node.keywords, prop)
+ self._properties.append(prop)
+ elif name == "Slot":
+ self._slots.append(_parse_slot(func_name, node))
+ else:
+ print('Unknown decorator with parameters:', name,
+ file=sys.stderr)
+
+ def _parse_class_variable(self, node: ast.Assign):
+ """Parse a class variable assignment (Property, Signal, etc.)"""
+ (var_name, call) = _parse_assignment(node)
+ if not var_name or not isinstance(node.value, ast.Call):
+ return
+ func_name = _func_name(call)
+ if func_name == "Signal" or func_name == "QtCore.Signal":
+ signal: Signal = {"access": "public", "name": var_name,
+ "arguments": _parse_call_args(call),
+ "returnType": "void"}
+ self._signals.append(signal)
+ elif func_name == "Property" or func_name == "QtCore.Property":
+ type = _python_to_cpp_type(call.args[0].id)
+ prop = self._create_property_entry(var_name, type, call.args[1].id)
+ if len(call.args) > 2:
+ prop["write"] = call.args[2].id
+ _parse_property_kwargs(call.keywords, prop)
+ self._properties.append(prop)
+ elif func_name == "ListProperty" or func_name == "QtCore.ListProperty":
+ type = _python_to_cpp_type(call.args[0].id)
+ type = f"QQmlListProperty<{type}>"
+ prop = self._create_property_entry(var_name, type)
+ self._properties.append(prop)
+
+ def visit_Import(self, node):
+ for n in node.names: # "import PySide6.QtWidgets"
+ self._handle_import(n.name)
+
+ def visit_ImportFrom(self, node):
+ if "." in node.module: # "from PySide6.QtWidgets import QWidget"
+ self._handle_import(node.module)
+ elif node.module == "PySide6": # "from PySide6 import QtWidgets"
+ for n in node.names:
+ if n.name.startswith("Qt"):
+ self._qt_modules.add(n.name)
+
+ def _handle_import(self, mod: str):
+ if mod.startswith("PySide6."):
+ self._qt_modules.add(mod[8:])
+
+
+def create_arg_parser(desc: str) -> ArgumentParser:
+ parser = ArgumentParser(description=desc,
+ formatter_class=RawTextHelpFormatter)
+ parser.add_argument('--compact', '-c', action='store_true',
+ help='Use compact format')
+ parser.add_argument('--suppress-file', '-s', action='store_true',
+ help='Suppress inputFile entry (for testing)')
+ parser.add_argument('--quiet', '-q', action='store_true',
+ help='Suppress warnings')
+ parser.add_argument('files', type=str, nargs="+",
+ help='Python source file')
+ parser.add_argument('--out-file', '-o', type=str,
+ help='Write output to file rather than stdout')
+ return parser
+
+
+def parse_file(file: Path, context: VisitorContext,
+ suppress_file: bool = False) -> Optional[Dict]:
+ """Parse a file and return its json data"""
+ ast_tree = MetaObjectDumpVisitor.create_ast(file)
+ visitor = MetaObjectDumpVisitor(context)
+ visitor.visit(ast_tree)
+
+ class_list = visitor.json_class_list()
+ if not class_list:
+ return None
+ result = {"classes": class_list,
+ "outputRevision": REVISION}
+
+ # Non-standard QML-related values for pyside6-build usage
+ if visitor.qml_import_name():
+ result[QML_IMPORT_NAME] = visitor.qml_import_name()
+ qml_import_version = visitor.qml_import_version()
+ if qml_import_version[0]:
+ result[QML_IMPORT_MAJOR_VERSION] = qml_import_version[0]
+ result[QML_IMPORT_MINOR_VERSION] = qml_import_version[1]
+
+ qt_modules = visitor.qt_modules()
+ if qt_modules:
+ result[QT_MODULES] = qt_modules
+
+ if not suppress_file:
+ result["inputFile"] = os.fspath(file).replace("\\", "/")
+ return result
+
+
+if __name__ == '__main__':
+ arg_parser = create_arg_parser(DESCRIPTION)
+ args = arg_parser.parse_args()
+
+ context = VisitorContext()
+ json_list = []
+
+ for file_name in args.files:
+ file = Path(file_name).resolve()
+ if not file.is_file():
+ print(f'{file_name} does not exist or is not a file.',
+ file=sys.stderr)
+ sys.exit(-1)
+
+ try:
+ json_data = parse_file(file, context, args.suppress_file)
+ if json_data:
+ json_list.append(json_data)
+ elif not args.quiet:
+ print(f"No classes found in {file_name}", file=sys.stderr)
+ except (AttributeError, SyntaxError) as e:
+ reason = str(e)
+ print(f"Error parsing {file_name}: {reason}", file=sys.stderr)
+ raise
+
+ indent = None if args.compact else 4
+ if args.out_file:
+ with open(args.out_file, 'w') as f:
+ json.dump(json_list, f, indent=indent)
+ else:
+ json.dump(json_list, sys.stdout, indent=indent)
diff --git a/sources/pyside-tools/project.py b/sources/pyside-tools/project.py
new file mode 100644
index 000000000..3706a2985
--- /dev/null
+++ b/sources/pyside-tools/project.py
@@ -0,0 +1,300 @@
+# 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
+
+
+"""
+Builds a '.pyproject' file
+
+Builds Qt Designer forms, resource files and QML type files
+
+Deploys the application by creating an executable for the corresponding platform
+
+For each entry in a '.pyproject' file:
+- <name>.pyproject: Recurse to handle subproject
+- <name>.qrc : Runs the resource compiler to create a file rc_<name>.py
+- <name>.ui : Runs the user interface compiler to create a file ui_<name>.py
+
+For a Python file declaring a QML module, a directory matching the URI is
+created and populated with .qmltypes and qmldir files for use by code analysis
+tools. Currently, only one QML module consisting of several classes can be
+handled per project file.
+"""
+import sys
+import os
+from typing import List, Tuple, Optional
+from pathlib import Path
+from argparse import ArgumentParser, RawTextHelpFormatter
+
+from project import (QmlProjectData, check_qml_decorators, is_python_file,
+ QMLDIR_FILE, MOD_CMD, METATYPES_JSON_SUFFIX,
+ SHADER_SUFFIXES, TRANSLATION_SUFFIX,
+ requires_rebuild, run_command, remove_path,
+ ProjectData, resolve_project_file, new_project,
+ ProjectType, ClOptions)
+
+MODE_HELP = """build Builds the project
+run Builds the project and runs the first file")
+clean Cleans the build artifacts")
+qmllint Runs the qmllint tool
+deploy Deploys the application
+lupdate Updates translation (.ts) files
+new-ui Creates a new QtWidgets project with a Qt Designer-based main window
+new-widget Creates a new QtWidgets project with a main window
+new-quick Creates a new QtQuick project
+"""
+
+UIC_CMD = "pyside6-uic"
+RCC_CMD = "pyside6-rcc"
+LRELEASE_CMD = "pyside6-lrelease"
+LUPDATE_CMD = "pyside6-lupdate"
+QMLTYPEREGISTRAR_CMD = "pyside6-qmltyperegistrar"
+QMLLINT_CMD = "pyside6-qmllint"
+QSB_CMD = "pyside6-qsb"
+DEPLOY_CMD = "pyside6-deploy"
+
+NEW_PROJECT_TYPES = {"new-quick": ProjectType.QUICK,
+ "new-ui": ProjectType.WIDGET_FORM,
+ "new-widget": ProjectType.WIDGET}
+
+
+def _sort_sources(files: List[Path]) -> List[Path]:
+ """Sort the sources for building, ensure .qrc is last since it might depend
+ on generated files."""
+
+ def key_func(p: Path):
+ return p.suffix if p.suffix != ".qrc" else ".zzzz"
+
+ return sorted(files, key=key_func)
+
+
+class Project:
+ """
+ Class to wrap the various operations on Project
+ """
+ def __init__(self, project_file: Path):
+ self.project = ProjectData(project_file=project_file)
+ self.cl_options = ClOptions()
+
+ # Files for QML modules using the QmlElement decorators
+ self._qml_module_sources: List[Path] = []
+ self._qml_module_dir: Optional[Path] = None
+ self._qml_dir_file: Optional[Path] = None
+ self._qml_project_data = QmlProjectData()
+ self._qml_module_check()
+
+ def _qml_module_check(self):
+ """Run a pre-check on Python source files and find the ones with QML
+ decorators (representing a QML module)."""
+ # Quick check for any QML files (to avoid running moc for no reason).
+ if not self.cl_options.qml_module and not self.project.qml_files:
+ return
+ for file in self.project.files:
+ if is_python_file(file):
+ has_class, data = check_qml_decorators(file)
+ if has_class:
+ self._qml_module_sources.append(file)
+ if data:
+ self._qml_project_data = data
+
+ if not self._qml_module_sources:
+ return
+ if not self._qml_project_data:
+ print("Detected QML-decorated files, " "but was unable to detect QML_IMPORT_NAME")
+ sys.exit(1)
+
+ self._qml_module_dir = self.project.project_file.parent
+ for uri_dir in self._qml_project_data.import_name.split("."):
+ self._qml_module_dir /= uri_dir
+ print(self._qml_module_dir)
+ self._qml_dir_file = self._qml_module_dir / QMLDIR_FILE
+
+ if not self.cl_options.quiet:
+ count = len(self._qml_module_sources)
+ print(f"{self.project.project_file.name}, {count} QML file(s),"
+ f" {self._qml_project_data}")
+
+ def _get_artifacts(self, file: Path) -> Tuple[List[Path], Optional[List[str]]]:
+ """Return path and command for a file's artifact"""
+ if file.suffix == ".ui": # Qt form files
+ py_file = f"{file.parent}/ui_{file.stem}.py"
+ return ([Path(py_file)], [UIC_CMD, os.fspath(file), "--rc-prefix", "-o", py_file])
+ if file.suffix == ".qrc": # Qt resources
+ py_file = f"{file.parent}/rc_{file.stem}.py"
+ return ([Path(py_file)], [RCC_CMD, os.fspath(file), "-o", py_file])
+ # generate .qmltypes from sources with Qml decorators
+ if file.suffix == ".py" and file in self._qml_module_sources:
+ assert self._qml_module_dir
+ qml_module_dir = os.fspath(self._qml_module_dir)
+ json_file = f"{qml_module_dir}/{file.stem}{METATYPES_JSON_SUFFIX}"
+ return ([Path(json_file)], [MOD_CMD, "-o", json_file, os.fspath(file)])
+ # Run qmltyperegistrar
+ if file.name.endswith(METATYPES_JSON_SUFFIX):
+ assert self._qml_module_dir
+ stem = file.name[: len(file.name) - len(METATYPES_JSON_SUFFIX)]
+ qmltypes_file = self._qml_module_dir / f"{stem}.qmltypes"
+ cpp_file = self._qml_module_dir / f"{stem}_qmltyperegistrations.cpp"
+ cmd = [QMLTYPEREGISTRAR_CMD, "--generate-qmltypes",
+ os.fspath(qmltypes_file), "-o", os.fspath(cpp_file),
+ os.fspath(file)]
+ cmd.extend(self._qml_project_data.registrar_options())
+ return ([qmltypes_file, cpp_file], cmd)
+
+ if file.name.endswith(TRANSLATION_SUFFIX):
+ qm_file = f"{file.parent}/{file.stem}.qm"
+ cmd = [LRELEASE_CMD, os.fspath(file), "-qm", qm_file]
+ return ([Path(qm_file)], cmd)
+
+ if file.suffix in SHADER_SUFFIXES:
+ qsb_file = f"{file.parent}/{file.stem}.qsb"
+ cmd = [QSB_CMD, "-o", qsb_file, os.fspath(file)]
+ return ([Path(qsb_file)], cmd)
+
+ return ([], None)
+
+ def _regenerate_qmldir(self):
+ """Regenerate the 'qmldir' file."""
+ if self.cl_options.dry_run or not self._qml_dir_file:
+ return
+ if self.cl_options.force or requires_rebuild(self._qml_module_sources, self._qml_dir_file):
+ with self._qml_dir_file.open("w") as qf:
+ qf.write(f"module {self._qml_project_data.import_name}\n")
+ for f in self._qml_module_dir.glob("*.qmltypes"):
+ qf.write(f"typeinfo {f.name}\n")
+
+ def _build_file(self, source: Path):
+ """Build an artifact."""
+ artifacts, command = self._get_artifacts(source)
+ for artifact in artifacts:
+ if self.cl_options.force or requires_rebuild([source], artifact):
+ run_command(command, cwd=self.project.project_file.parent)
+ self._build_file(artifact) # Recurse for QML (json->qmltypes)
+
+ def build(self):
+ """Build."""
+ for sub_project_file in self.project.sub_projects_files:
+ Project(project_file=sub_project_file).build()
+ if self._qml_module_dir:
+ self._qml_module_dir.mkdir(exist_ok=True, parents=True)
+ for file in _sort_sources(self.project.files):
+ self._build_file(file)
+ self._regenerate_qmldir()
+
+ def run(self):
+ """Runs the project"""
+ self.build()
+ cmd = [sys.executable, str(self.project.main_file)]
+ run_command(cmd, cwd=self.project.project_file.parent)
+
+ def _clean_file(self, source: Path):
+ """Clean an artifact."""
+ artifacts, command = self._get_artifacts(source)
+ for artifact in artifacts:
+ remove_path(artifact)
+ self._clean_file(artifact) # Recurse for QML (json->qmltypes)
+
+ def clean(self):
+ """Clean build artifacts."""
+ for sub_project_file in self.project.sub_projects_files:
+ Project(project_file=sub_project_file).clean()
+ for file in self.project.files:
+ self._clean_file(file)
+ if self._qml_module_dir and self._qml_module_dir.is_dir():
+ remove_path(self._qml_module_dir)
+ # In case of a dir hierarchy ("a.b" -> a/b), determine and delete
+ # the root directory
+ if self._qml_module_dir.parent != self.project.project_file.parent:
+ project_dir_parts = len(self.project.project_file.parent.parts)
+ first_module_dir = self._qml_module_dir.parts[project_dir_parts]
+ remove_path(self.project.project_file.parent / first_module_dir)
+
+ def _qmllint(self):
+ """Helper for running qmllint on .qml files (non-recursive)."""
+ if not self.project.qml_files:
+ print(f"{self.project.project_file.name}: No QML files found", file=sys.stderr)
+ return
+
+ cmd = [QMLLINT_CMD]
+ if self._qml_dir_file:
+ cmd.extend(["-i", os.fspath(self._qml_dir_file)])
+ for f in self.project.qml_files:
+ cmd.append(os.fspath(f))
+ run_command(cmd, cwd=self.project.project_file.parent, ignore_fail=True)
+
+ def qmllint(self):
+ """Run qmllint on .qml files."""
+ self.build()
+ for sub_project_file in self.project.sub_projects_files:
+ Project(project_file=sub_project_file)._qmllint()
+ self._qmllint()
+
+ def deploy(self):
+ """Deploys the application"""
+ cmd = [DEPLOY_CMD]
+ cmd.extend([str(self.project.main_file), "-f"])
+ run_command(cmd, cwd=self.project.project_file.parent)
+
+ def lupdate(self):
+ for sub_project_file in self.project.sub_projects_files:
+ Project(project_file=sub_project_file).lupdate()
+
+ if not self.project.ts_files:
+ print(f"{self.project.project_file.name}: No .ts file found.",
+ file=sys.stderr)
+ return
+
+ source_files = self.project.python_files + self.project.ui_files
+ cmd_prefix = [LUPDATE_CMD] + [p.name for p in source_files]
+ cmd_prefix.append("-ts")
+ for ts_file in self.project.ts_files:
+ if requires_rebuild(source_files, ts_file):
+ cmd = cmd_prefix
+ cmd.append(ts_file.name)
+ run_command(cmd, cwd=self.project.project_file.parent)
+
+
+if __name__ == "__main__":
+ parser = ArgumentParser(description=__doc__, formatter_class=RawTextHelpFormatter)
+ parser.add_argument("--quiet", "-q", action="store_true", help="Quiet")
+ parser.add_argument("--dry-run", "-n", action="store_true", help="Only print commands")
+ parser.add_argument("--force", "-f", action="store_true", help="Force rebuild")
+ parser.add_argument("--qml-module", "-Q", action="store_true",
+ help="Perform check for QML module")
+ mode_choices = ["build", "run", "clean", "qmllint", "deploy", "lupdate"]
+ mode_choices.extend(NEW_PROJECT_TYPES.keys())
+ parser.add_argument("mode", choices=mode_choices, default="build",
+ type=str, help=MODE_HELP)
+ parser.add_argument("file", help="Project file", nargs="?", type=str)
+
+ options = parser.parse_args()
+ cl_options = ClOptions(dry_run=options.dry_run, quiet=options.quiet, force=options.force,
+ qml_module=options.qml_module)
+
+ mode = options.mode
+
+ new_project_type = NEW_PROJECT_TYPES.get(mode)
+ if new_project_type:
+ if not options.file:
+ print(f"{mode} requires a directory name.", file=sys.stderr)
+ sys.exit(1)
+ sys.exit(new_project(options.file, new_project_type))
+
+ project_file = resolve_project_file(options.file)
+ if not project_file:
+ print(f"Cannot determine project_file {options.file}", file=sys.stderr)
+ sys.exit(1)
+ project = Project(project_file)
+ if mode == "build":
+ project.build()
+ elif mode == "run":
+ project.run()
+ elif mode == "clean":
+ project.clean()
+ elif mode == "qmllint":
+ project.qmllint()
+ elif mode == "deploy":
+ project.deploy()
+ elif mode == "lupdate":
+ project.lupdate()
+ else:
+ print(f"Invalid mode {mode}", file=sys.stderr)
+ sys.exit(1)
diff --git a/sources/pyside-tools/project.pyproject b/sources/pyside-tools/project.pyproject
new file mode 100644
index 000000000..346ef0465
--- /dev/null
+++ b/sources/pyside-tools/project.pyproject
@@ -0,0 +1,4 @@
+{
+ "files": ["project.py", "project/__init__.py", "project/newproject.py",
+ "project/project_data.py", "project/utils.py"]
+}
diff --git a/sources/pyside-tools/project/__init__.py b/sources/pyside-tools/project/__init__.py
new file mode 100644
index 000000000..e57a9ff88
--- /dev/null
+++ b/sources/pyside-tools/project/__init__.py
@@ -0,0 +1,46 @@
+# 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
+
+from dataclasses import dataclass
+
+QTPATHS_CMD = "qtpaths6"
+MOD_CMD = "pyside6-metaobjectdump"
+
+PROJECT_FILE_SUFFIX = ".pyproject"
+QMLDIR_FILE = "qmldir"
+
+QML_IMPORT_NAME = "QML_IMPORT_NAME"
+QML_IMPORT_MAJOR_VERSION = "QML_IMPORT_MAJOR_VERSION"
+QML_IMPORT_MINOR_VERSION = "QML_IMPORT_MINOR_VERSION"
+QT_MODULES = "QT_MODULES"
+
+METATYPES_JSON_SUFFIX = "metatypes.json"
+TRANSLATION_SUFFIX = ".ts"
+SHADER_SUFFIXES = ".vert", ".frag"
+
+
+class Singleton(type):
+ _instances = {}
+
+ def __call__(cls, *args, **kwargs):
+ if cls not in cls._instances:
+ cls._instances[cls] = super().__call__(*args, **kwargs)
+ return cls._instances[cls]
+
+
+@dataclass(frozen=True)
+class ClOptions(metaclass=Singleton):
+ """
+ Dataclass to store the cl options that needs to be passed as arguments.
+ """
+ dry_run: bool
+ quiet: bool
+ force: bool
+ qml_module: bool
+
+
+from .utils import (run_command, requires_rebuild, remove_path, package_dir, qtpaths,
+ qt_metatype_json_dir, resolve_project_file)
+from .project_data import (is_python_file, ProjectData, QmlProjectData,
+ check_qml_decorators)
+from .newproject import new_project, ProjectType
diff --git a/sources/pyside-tools/project/newproject.py b/sources/pyside-tools/project/newproject.py
new file mode 100644
index 000000000..c363a9fc0
--- /dev/null
+++ b/sources/pyside-tools/project/newproject.py
@@ -0,0 +1,165 @@
+# 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 os
+import sys
+from enum import Enum
+from pathlib import Path
+from typing import List, Tuple
+
+"""New project generation code."""
+
+
+Project = List[Tuple[str, str]] # tuple of (filename, contents).
+
+
+class ProjectType(Enum):
+ WIDGET_FORM = 1
+ WIDGET = 2
+ QUICK = 3
+
+
+_WIDGET_MAIN = """if __name__ == '__main__':
+ app = QApplication(sys.argv)
+ window = MainWindow()
+ window.show()
+ sys.exit(app.exec())
+"""
+
+
+_WIDGET_IMPORTS = """import sys
+from PySide6.QtWidgets import QApplication, QMainWindow
+"""
+
+
+_WIDGET_CLASS_DEFINITION = """class MainWindow(QMainWindow):
+ def __init__(self):
+ super().__init__()
+"""
+
+
+_WIDGET_SETUP_UI_CODE = """ self._ui = Ui_MainWindow()
+ self._ui.setupUi(self)
+"""
+
+
+_MAINWINDOW_FORM = """<?xml version="1.0" encoding="UTF-8"?>
+<ui version="4.0">
+ <class>MainWindow</class>
+ <widget class="QMainWindow" name="MainWindow">
+ <property name="geometry">
+ <rect>
+ <x>0</x>
+ <y>0</y>
+ <width>800</width>
+ <height>600</height>
+ </rect>
+ </property>
+ <property name="windowTitle">
+ <string>MainWindow</string>
+ </property>
+ <widget class="QWidget" name="centralwidget"/>
+ <widget class="QMenuBar" name="menubar">
+ <property name="geometry">
+ <rect>
+ <x>0</x>
+ <y>0</y>
+ <width>800</width>
+ <height>22</height>
+ </rect>
+ </property>
+ </widget>
+ <widget class="QStatusBar" name="statusbar"/>
+ </widget>
+</ui>
+"""
+
+
+_QUICK_FORM = """import QtQuick
+import QtQuick.Controls
+
+ApplicationWindow {
+ id: window
+ width: 1024
+ height: 600
+ visible: true
+}
+"""
+
+_QUICK_MAIN = """import sys
+from pathlib import Path
+
+from PySide6.QtGui import QGuiApplication
+from PySide6.QtCore import QUrl
+from PySide6.QtQml import QQmlApplicationEngine
+
+
+if __name__ == "__main__":
+ app = QGuiApplication()
+ engine = QQmlApplicationEngine()
+ qml_file = Path(__file__).parent / 'main.qml'
+ engine.load(QUrl.fromLocalFile(qml_file))
+ if not engine.rootObjects():
+ sys.exit(-1)
+ exit_code = app.exec()
+ del engine
+ sys.exit(exit_code)
+"""
+
+
+def _write_project(directory: Path, files: Project):
+ """Write out the project."""
+ file_list = []
+ for file, contents in files:
+ (directory / file).write_text(contents)
+ print(f"Wrote {directory.name}{os.sep}{file}.")
+ file_list.append(file)
+ pyproject = {"files": file_list}
+ pyproject_file = f"{directory}.pyproject"
+ (directory / pyproject_file).write_text(json.dumps(pyproject))
+ print(f"Wrote {directory.name}{os.sep}{pyproject_file}.")
+
+
+def _widget_project() -> Project:
+ """Create a (form-less) widgets project."""
+ main_py = (_WIDGET_IMPORTS + "\n\n" + _WIDGET_CLASS_DEFINITION + "\n\n"
+ + _WIDGET_MAIN)
+ return [("main.py", main_py)]
+
+
+def _ui_form_project() -> Project:
+ """Create a Qt Designer .ui form based widgets project."""
+ main_py = (_WIDGET_IMPORTS
+ + "\nfrom ui_mainwindow import Ui_MainWindow\n\n\n"
+ + _WIDGET_CLASS_DEFINITION + _WIDGET_SETUP_UI_CODE
+ + "\n\n" + _WIDGET_MAIN)
+ return [("main.py", main_py),
+ ("mainwindow.ui", _MAINWINDOW_FORM)]
+
+
+def _qml_project() -> Project:
+ """Create a QML project."""
+ return [("main.py", _QUICK_MAIN),
+ ("main.qml", _QUICK_FORM)]
+
+
+def new_project(directory_s: str,
+ project_type: ProjectType = ProjectType.WIDGET_FORM) -> int:
+ directory = Path(directory_s)
+ if directory.exists():
+ print(f"{directory_s} already exists.", file=sys.stderr)
+ return -1
+ directory.mkdir(parents=True)
+
+ if project_type == ProjectType.WIDGET_FORM:
+ project = _ui_form_project()
+ elif project_type == ProjectType.QUICK:
+ project = _qml_project()
+ else:
+ project = _widget_project()
+ _write_project(directory, project)
+ if project_type == ProjectType.WIDGET_FORM:
+ print(f'Run "pyside6-project build {directory_s}" to build the project')
+ print(f'Run "python {directory.name}{os.sep}main.py" to run the project')
+ return 0
diff --git a/sources/pyside-tools/project/project_data.py b/sources/pyside-tools/project/project_data.py
new file mode 100644
index 000000000..52e20be3f
--- /dev/null
+++ b/sources/pyside-tools/project/project_data.py
@@ -0,0 +1,244 @@
+# 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 os
+import subprocess
+import sys
+from typing import List, Tuple
+from pathlib import Path
+from . import (METATYPES_JSON_SUFFIX, PROJECT_FILE_SUFFIX, TRANSLATION_SUFFIX,
+ qt_metatype_json_dir, MOD_CMD, QML_IMPORT_MAJOR_VERSION,
+ QML_IMPORT_MINOR_VERSION, QML_IMPORT_NAME, QT_MODULES)
+
+
+def is_python_file(file: Path) -> bool:
+ return (file.suffix == ".py"
+ or sys.platform == "win32" and file.suffix == ".pyw")
+
+
+class ProjectData:
+ def __init__(self, project_file: Path) -> None:
+ """Parse the project."""
+ self._project_file = project_file
+ self._sub_projects_files: List[Path] = []
+
+ # All sources except subprojects
+ self._files: List[Path] = []
+ # QML files
+ self._qml_files: List[Path] = []
+ # Python files
+ self.main_file: Path = None
+ self._python_files: List[Path] = []
+ # ui files
+ self._ui_files: List[Path] = []
+ # qrc files
+ self._qrc_files: List[Path] = []
+ # ts files
+ self._ts_files: List[Path] = []
+
+ with project_file.open("r") as pyf:
+ pyproject = json.load(pyf)
+ for f in pyproject["files"]:
+ file = Path(project_file.parent / f)
+ if file.suffix == PROJECT_FILE_SUFFIX:
+ self._sub_projects_files.append(file)
+ else:
+ self._files.append(file)
+ if file.suffix == ".qml":
+ self._qml_files.append(file)
+ elif is_python_file(file):
+ if file.stem == "main":
+ self.main_file = file
+ self._python_files.append(file)
+ elif file.suffix == ".ui":
+ self._ui_files.append(file)
+ elif file.suffix == ".qrc":
+ self._qrc_files.append(file)
+ elif file.suffix == TRANSLATION_SUFFIX:
+ self._ts_files.append(file)
+
+ if not self.main_file:
+ self._find_main_file()
+
+ @property
+ def project_file(self):
+ return self._project_file
+
+ @property
+ def files(self):
+ return self._files
+
+ @property
+ def main_file(self):
+ return self._main_file
+
+ @main_file.setter
+ def main_file(self, main_file):
+ self._main_file = main_file
+
+ @property
+ def python_files(self):
+ return self._python_files
+
+ @property
+ def ui_files(self):
+ return self._ui_files
+
+ @property
+ def qrc_files(self):
+ return self._qrc_files
+
+ @property
+ def qml_files(self):
+ return self._qml_files
+
+ @property
+ def ts_files(self):
+ return self._ts_files
+
+ @property
+ def sub_projects_files(self):
+ return self._sub_projects_files
+
+ def _find_main_file(self) -> str:
+ """Find the entry point file containing the main function"""
+
+ def is_main(file):
+ return "__main__" in file.read_text(encoding="utf-8")
+
+ if not self.main_file:
+ for python_file in self.python_files:
+ if is_main(python_file):
+ self.main_file = python_file
+ return str(python_file)
+
+ # __main__ not found
+ print(
+ "Python file with main function not found. Add the file to" f" {self.project_file}",
+ file=sys.stderr,
+ )
+ sys.exit(1)
+
+
+class QmlProjectData:
+ """QML relevant project data."""
+
+ def __init__(self):
+ self._import_name: str = ""
+ self._import_major_version: int = 0
+ self._import_minor_version: int = 0
+ self._qt_modules: List[str] = []
+
+ def registrar_options(self):
+ result = [
+ "--import-name",
+ self._import_name,
+ "--major-version",
+ str(self._import_major_version),
+ "--minor-version",
+ str(self._import_minor_version),
+ ]
+ if self._qt_modules:
+ # Add Qt modules as foreign types
+ foreign_files: List[str] = []
+ meta_dir = qt_metatype_json_dir()
+ for mod in self._qt_modules:
+ mod_id = mod[2:].lower()
+ pattern = f"qt6{mod_id}_*"
+ if sys.platform != "win32":
+ pattern += "_" # qt6core_debug_metatypes.json (Linux)
+ pattern += METATYPES_JSON_SUFFIX
+ for f in meta_dir.glob(pattern):
+ foreign_files.append(os.fspath(f))
+ break
+ if foreign_files:
+ foreign_files_str = ",".join(foreign_files)
+ result.append(f"--foreign-types={foreign_files_str}")
+ return result
+
+ @property
+ def import_name(self):
+ return self._import_name
+
+ @import_name.setter
+ def import_name(self, n):
+ self._import_name = n
+
+ @property
+ def import_major_version(self):
+ return self._import_major_version
+
+ @import_major_version.setter
+ def import_major_version(self, v):
+ self._import_major_version = v
+
+ @property
+ def import_minor_version(self):
+ return self._import_minor_version
+
+ @import_minor_version.setter
+ def import_minor_version(self, v):
+ self._import_minor_version = v
+
+ @property
+ def qt_modules(self):
+ return self._qt_modules
+
+ @qt_modules.setter
+ def qt_modules(self, v):
+ self._qt_modules = v
+
+ def __str__(self) -> str:
+ vmaj = self._import_major_version
+ vmin = self._import_minor_version
+ return f'"{self._import_name}" v{vmaj}.{vmin}'
+
+ def __bool__(self) -> bool:
+ return len(self._import_name) > 0 and self._import_major_version > 0
+
+
+def _has_qml_decorated_class(class_list: List) -> bool:
+ """Check for QML-decorated classes in the moc json output."""
+ for d in class_list:
+ class_infos = d.get("classInfos")
+ if class_infos:
+ for e in class_infos:
+ if "QML" in e["name"]:
+ return True
+ return False
+
+
+def check_qml_decorators(py_file: Path) -> Tuple[bool, QmlProjectData]:
+ """Check if a Python file has QML-decorated classes by running a moc check
+ and return whether a class was found and the QML data."""
+ data = None
+ try:
+ cmd = [MOD_CMD, "--quiet", os.fspath(py_file)]
+ with subprocess.Popen(cmd, stdout=subprocess.PIPE) as proc:
+ data = json.load(proc.stdout)
+ proc.wait()
+ except Exception as e:
+ t = type(e).__name__
+ print(f"{t}: running {MOD_CMD} on {py_file}: {e}", file=sys.stderr)
+ sys.exit(1)
+
+ qml_project_data = QmlProjectData()
+ if not data:
+ return (False, qml_project_data) # No classes in file
+
+ first = data[0]
+ class_list = first["classes"]
+ has_class = _has_qml_decorated_class(class_list)
+ if has_class:
+ v = first.get(QML_IMPORT_NAME)
+ if v:
+ qml_project_data.import_name = v
+ v = first.get(QML_IMPORT_MAJOR_VERSION)
+ if v:
+ qml_project_data.import_major_version = v
+ qml_project_data.import_minor_version = first.get(QML_IMPORT_MINOR_VERSION)
+ v = first.get(QT_MODULES)
+ if v:
+ qml_project_data.qt_modules = v
+ return (has_class, qml_project_data)
diff --git a/sources/pyside-tools/project/utils.py b/sources/pyside-tools/project/utils.py
new file mode 100644
index 000000000..d2bff65af
--- /dev/null
+++ b/sources/pyside-tools/project/utils.py
@@ -0,0 +1,107 @@
+# 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 subprocess
+from pathlib import Path
+from typing import List, Dict, Optional
+
+from . import QTPATHS_CMD, PROJECT_FILE_SUFFIX, ClOptions
+
+
+def run_command(command: List[str], cwd: str = None, ignore_fail: bool = False):
+ """Run a command observing quiet/dry run"""
+ cloptions = ClOptions()
+ if not cloptions.quiet or cloptions.dry_run:
+ print(" ".join(command))
+ if not cloptions.dry_run:
+ ex = subprocess.call(command, cwd=cwd)
+ if ex != 0 and not ignore_fail:
+ sys.exit(ex)
+
+
+def requires_rebuild(sources: List[Path], artifact: Path) -> bool:
+ """Returns whether artifact needs to be rebuilt depending on sources"""
+ if not artifact.is_file():
+ return True
+ artifact_mod_time = artifact.stat().st_mtime
+ for source in sources:
+ if source.stat().st_mtime > artifact_mod_time:
+ return True
+ return False
+
+
+def _remove_path_recursion(path: Path):
+ """Recursion to remove a file or directory."""
+ if path.is_file():
+ path.unlink()
+ elif path.is_dir():
+ for item in path.iterdir():
+ _remove_path_recursion(item)
+ path.rmdir()
+
+
+def remove_path(path: Path):
+ """Remove path (file or directory) observing opt_dry_run."""
+ cloptions = ClOptions()
+ if not path.exists():
+ return
+ if not cloptions.quiet:
+ print(f"Removing {path.name}...")
+ if cloptions.dry_run:
+ return
+ _remove_path_recursion(path)
+
+
+def package_dir() -> Path:
+ """Return the PySide6 root."""
+ return Path(__file__).resolve().parents[2]
+
+
+_qtpaths_info: Dict[str, str] = {}
+
+
+def qtpaths() -> Dict[str, str]:
+ """Run qtpaths and return a dict of values."""
+ global _qtpaths_info
+ if not _qtpaths_info:
+ output = subprocess.check_output([QTPATHS_CMD, "--query"])
+ for line in output.decode("utf-8").split("\n"):
+ tokens = line.strip().split(":", maxsplit=1) # "Path=C:\..."
+ if len(tokens) == 2:
+ _qtpaths_info[tokens[0]] = tokens[1]
+ return _qtpaths_info
+
+
+_qt_metatype_json_dir: Optional[Path] = None
+
+
+def qt_metatype_json_dir() -> Path:
+ """Return the location of the Qt QML metatype files."""
+ global _qt_metatype_json_dir
+ if not _qt_metatype_json_dir:
+ qt_dir = package_dir()
+ if sys.platform != "win32":
+ qt_dir /= "Qt"
+ metatypes_dir = qt_dir / "metatypes"
+ if metatypes_dir.is_dir(): # Fully installed case
+ _qt_metatype_json_dir = metatypes_dir
+ else:
+ # Fallback for distro builds/development.
+ print(
+ f"Falling back to {QTPATHS_CMD} to determine metatypes directory.", file=sys.stderr
+ )
+ _qt_metatype_json_dir = Path(qtpaths()["QT_INSTALL_ARCHDATA"]) / "metatypes"
+ return _qt_metatype_json_dir
+
+
+def resolve_project_file(cmdline: str) -> Optional[Path]:
+ """Return the project file from the command line value, either
+ from the file argument or directory"""
+ project_file = Path(cmdline).resolve() if cmdline else Path.cwd()
+ if project_file.is_file():
+ return project_file
+ if project_file.is_dir():
+ for m in project_file.glob(f"*{PROJECT_FILE_SUFFIX}"):
+ return m
+ return None
diff --git a/sources/pyside-tools/pyside_tool.py b/sources/pyside-tools/pyside_tool.py
new file mode 100644
index 000000000..b369be8a2
--- /dev/null
+++ b/sources/pyside-tools/pyside_tool.py
@@ -0,0 +1,243 @@
+#!/usr/bin/env python
+# 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 importlib
+import os
+import subprocess
+import sys
+import sysconfig
+from pathlib import Path
+
+import PySide6 as ref_mod
+
+VIRTUAL_ENV = "VIRTUAL_ENV"
+
+
+def is_pyenv_python():
+ pyenv_root = os.environ.get("PYENV_ROOT")
+
+ if pyenv_root:
+ resolved_exe = Path(sys.executable).resolve()
+ if str(resolved_exe).startswith(pyenv_root):
+ return True
+
+ return False
+
+
+def is_virtual_env():
+ return sys.prefix != sys.base_prefix
+
+
+def init_virtual_env():
+ """PYSIDE-2251: Enable running from a non-activated virtual environment
+ as is the case for Visual Studio Code by setting the VIRTUAL_ENV
+ variable which is used by the Qt Designer plugin."""
+ if is_virtual_env() and not os.environ.get(VIRTUAL_ENV):
+ os.environ[VIRTUAL_ENV] = sys.prefix
+
+
+def main():
+ # This will take care of "pyside6-lupdate" listed as an entrypoint
+ # in setup.py are copied to 'scripts/..'
+ cmd = os.path.join("..", os.path.basename(sys.argv[0]))
+ command = [os.path.join(os.path.dirname(os.path.realpath(__file__)), cmd)]
+ command.extend(sys.argv[1:])
+ sys.exit(subprocess.call(command))
+
+
+def qt_tool_wrapper(qt_tool, args, libexec=False):
+ # Taking care of pyside6-uic, pyside6-rcc, and pyside6-designer
+ # listed as an entrypoint in setup.py
+ pyside_dir = Path(ref_mod.__file__).resolve().parent
+ if libexec and sys.platform != "win32":
+ exe = pyside_dir / 'Qt' / 'libexec' / qt_tool
+ else:
+ exe = pyside_dir / qt_tool
+
+ cmd = [os.fspath(exe)] + args
+ returncode = subprocess.call(cmd)
+ if returncode != 0:
+ command = ' '.join(cmd)
+ print(f"'{command}' returned {returncode}", file=sys.stderr)
+ sys.exit(returncode)
+
+
+def pyside_script_wrapper(script_name):
+ """Launch a script shipped with PySide."""
+ script = Path(__file__).resolve().parent / script_name
+ command = [sys.executable, os.fspath(script)] + sys.argv[1:]
+ sys.exit(subprocess.call(command))
+
+
+def ui_tool_binary(binary):
+ """Return the binary of a UI tool (App bundle on macOS)."""
+ if sys.platform != "darwin":
+ return binary
+ name = binary[0:1].upper() + binary[1:]
+ return f"{name}.app/Contents/MacOS/{name}"
+
+
+def lrelease():
+ qt_tool_wrapper("lrelease", sys.argv[1:])
+
+
+def lupdate():
+ qt_tool_wrapper("lupdate", sys.argv[1:])
+
+
+def uic():
+ qt_tool_wrapper("uic", ['-g', 'python'] + sys.argv[1:], True)
+
+
+def rcc():
+ args = []
+ user_args = sys.argv[1:]
+ if "--binary" not in user_args:
+ args.extend(['-g', 'python'])
+ args.extend(user_args)
+ qt_tool_wrapper("rcc", args, True)
+
+
+def qmltyperegistrar():
+ qt_tool_wrapper("qmltyperegistrar", sys.argv[1:], True)
+
+
+def qmlimportscanner():
+ qt_tool_wrapper("qmlimportscanner", sys.argv[1:], True)
+
+
+def qmlcachegen():
+ qt_tool_wrapper("qmlcachegen", sys.argv[1:], True)
+
+
+def qmllint():
+ qt_tool_wrapper("qmllint", sys.argv[1:])
+
+
+def qmlformat():
+ qt_tool_wrapper("qmlformat", sys.argv[1:])
+
+
+def qmlls():
+ qt_tool_wrapper("qmlls", sys.argv[1:])
+
+
+def assistant():
+ qt_tool_wrapper(ui_tool_binary("assistant"), sys.argv[1:])
+
+
+def _extend_path_var(var, value, prepend=False):
+ env_value = os.environ.get(var)
+ if env_value:
+ env_value = (f'{value}{os.pathsep}{env_value}'
+ if prepend else f'{env_value}{os.pathsep}{value}')
+ else:
+ env_value = value
+ os.environ[var] = env_value
+
+
+def designer():
+ init_virtual_env()
+
+ # https://www.python.org/dev/peps/pep-0384/#linkage :
+ # "On Unix systems, the ABI is typically provided by the python executable
+ # itself", that is, libshiboken does not link against any Python library
+ # and expects to get these symbols from a python executable. Since no
+ # python executable is involved when loading this plugin, pre-load python.so
+ # This should also help to work around a numpy issue, see
+ # https://stackoverflow.com/questions/49784583/numpy-import-fails-on-multiarray-extension-library-when-called-from-embedded-pyt
+ major_version = sys.version_info[0]
+ minor_version = sys.version_info[1]
+ os.environ['PY_MAJOR_VERSION'] = str(major_version)
+ os.environ['PY_MINOR_VERSION'] = str(minor_version)
+ if sys.platform == 'linux':
+ # Determine library name (examples/utils/pyside_config.py)
+ version = f'{major_version}.{minor_version}'
+ library_name = f'libpython{version}{sys.abiflags}.so'
+ if is_pyenv_python():
+ library_name = str(Path(sysconfig.get_config_var('LIBDIR')) / library_name)
+ os.environ['LD_PRELOAD'] = library_name
+ elif sys.platform == 'darwin':
+ library_name = sysconfig.get_config_var("LDLIBRARY")
+ framework_prefix = sysconfig.get_config_var("PYTHONFRAMEWORKPREFIX")
+ lib_path = None
+ if framework_prefix:
+ lib_path = os.fspath(Path(framework_prefix) / library_name)
+ elif is_pyenv_python():
+ lib_path = str(Path(sysconfig.get_config_var('LIBDIR')) / library_name)
+ else:
+ # ideally this should never be reached because the system Python and Python installed
+ # from python.org are all framework builds
+ print("Unable to find Python library directory. Use a framework build of Python.",
+ file=sys.stderr)
+ sys.exit(0)
+ os.environ['DYLD_INSERT_LIBRARIES'] = lib_path
+ elif sys.platform == 'win32':
+ # Find Python DLLs from the base installation
+ if is_virtual_env():
+ _extend_path_var("PATH", os.fspath(Path(sys._base_executable).parent), True)
+
+ qt_tool_wrapper(ui_tool_binary("designer"), sys.argv[1:])
+
+
+def linguist():
+ qt_tool_wrapper(ui_tool_binary("linguist"), sys.argv[1:])
+
+
+def genpyi():
+ pyside_dir = Path(__file__).resolve().parents[1]
+ support = pyside_dir / "support"
+ cmd = support / "generate_pyi.py"
+ command = [sys.executable, os.fspath(cmd)] + sys.argv[1:]
+ sys.exit(subprocess.call(command))
+
+
+def metaobjectdump():
+ pyside_script_wrapper("metaobjectdump.py")
+
+
+def project():
+ pyside_script_wrapper("project.py")
+
+
+def qml():
+ pyside_script_wrapper("qml.py")
+
+
+def qtpy2cpp():
+ pyside_script_wrapper("qtpy2cpp.py")
+
+
+def deploy():
+ pyside_script_wrapper("deploy.py")
+
+
+def android_deploy():
+ if not sys.platform == "linux":
+ print("pyside6-android-deploy only works from a Linux host")
+ else:
+ android_requirements_file = Path(__file__).parent / "requirements-android.txt"
+ with open(android_requirements_file, 'r', encoding='UTF-8') as file:
+ while line := file.readline():
+ dependent_package = line.rstrip()
+ if not bool(importlib.util.find_spec(dependent_package)):
+ command = [sys.executable, "-m", "pip", "install", dependent_package]
+ subprocess.run(command)
+ pyside_script_wrapper("android_deploy.py")
+
+
+def qsb():
+ qt_tool_wrapper("qsb", sys.argv[1:])
+
+
+def balsam():
+ qt_tool_wrapper("balsam", sys.argv[1:])
+
+
+def balsamui():
+ qt_tool_wrapper("balsamui", sys.argv[1:])
+
+
+if __name__ == "__main__":
+ main()
diff --git a/sources/pyside-tools/qml.py b/sources/pyside-tools/qml.py
new file mode 100644
index 000000000..5d029f93d
--- /dev/null
+++ b/sources/pyside-tools/qml.py
@@ -0,0 +1,246 @@
+# 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
+
+"""pyside6-qml tool implementation. This tool mimics the capabilities of qml runtime utility
+for python and enables quick protyping with python modules"""
+
+import argparse
+import importlib.util
+import logging
+import sys
+import os
+from pathlib import Path
+from pprint import pprint
+from typing import List, Set
+
+from PySide6.QtCore import QCoreApplication, Qt, QLibraryInfo, QUrl, SignalInstance
+from PySide6.QtGui import QGuiApplication, QSurfaceFormat
+from PySide6.QtQml import QQmlApplicationEngine, QQmlComponent
+from PySide6.QtQuick import QQuickView, QQuickItem
+from PySide6.QtWidgets import QApplication
+
+
+def import_qml_modules(qml_parent_path: Path, module_paths: List[Path] = []):
+ '''
+ Import all the python modules in the qml_parent_path. This way all the classes
+ containing the @QmlElement/@QmlNamedElement are also imported
+
+ Parameters:
+ qml_parent_path (Path): Parent directory of the qml file
+ module_paths (int): user give import paths obtained through cli
+ '''
+
+ search_dir_paths = []
+ search_file_paths = []
+
+ if not module_paths:
+ search_dir_paths.append(qml_parent_path)
+ else:
+ for module_path in module_paths:
+ if module_path.is_dir():
+ search_dir_paths.append(module_path)
+ elif module_path.exists() and module_path.suffix == ".py":
+ search_file_paths.append(module_path)
+
+ def import_module(import_module_paths: Set[Path]):
+ """Import the modules in 'import_module_paths'"""
+ for module_path in import_module_paths:
+ module_name = module_path.name[:-3]
+ _spec = importlib.util.spec_from_file_location(f"{module_name}", module_path)
+ _module = importlib.util.module_from_spec(_spec)
+ _spec.loader.exec_module(module=_module)
+
+ modules_to_import = set()
+ for search_path in search_dir_paths:
+ possible_modules = list(search_path.glob("**/*.py"))
+ for possible_module in possible_modules:
+ if possible_module.is_file() and possible_module.name != "__init__.py":
+ module_parent = str(possible_module.parent)
+ if module_parent not in sys.path:
+ sys.path.append(module_parent)
+ modules_to_import.add(possible_module)
+
+ for search_path in search_file_paths:
+ sys.path.append(str(search_path.parent))
+ modules_to_import.add(search_path)
+
+ import_module(import_module_paths=modules_to_import)
+
+
+def print_configurations():
+ return "Built-in configurations \n\t default \n\t resizeToItem"
+
+
+if __name__ == "__main__":
+ parser = argparse.ArgumentParser(
+ description="This tools mimics the capabilities of qml runtime utility by directly"
+ " invoking QQmlEngine/QQuickView. It enables quick prototyping with qml files.",
+ formatter_class=argparse.RawTextHelpFormatter
+ )
+ parser.add_argument(
+ "file",
+ type=lambda p: Path(p).absolute(),
+ help="Path to qml file to display",
+ )
+ parser.add_argument(
+ "--module-paths", "-I",
+ type=lambda p: Path(p).absolute(),
+ nargs="+",
+ help="Specify space separated folder/file paths where the Qml classes are defined. By"
+ " default,the parent directory of the qml_path is searched recursively for all .py"
+ " files and they are imported. Otherwise only the paths give in module paths are"
+ " searched",
+ )
+ parser.add_argument(
+ "--list-conf",
+ action="version",
+ help="List the built-in configurations.",
+ version=print_configurations()
+ )
+ parser.add_argument(
+ "--apptype", "-a",
+ choices=["core", "gui", "widget"],
+ default="gui",
+ help="Select which application class to use. Default is gui",
+ )
+ parser.add_argument(
+ "--config", "-c",
+ choices=["default", "resizeToItem"],
+ default="default",
+ help="Select the built-in configurations.",
+ )
+ parser.add_argument(
+ "--rhi", "-r",
+ choices=["vulkan", "metal", "d3dll", "gl"],
+ help="Set the backend for the Qt graphics abstraction (RHI).",
+ )
+ parser.add_argument(
+ "--core-profile",
+ action="store_true",
+ help="Force use of OpenGL Core Profile.",
+ )
+ parser.add_argument(
+ '-v', '--verbose',
+ help="Print information about what qml is doing, like specific file URLs being loaded.",
+ action="store_const", dest="loglevel", const=logging.INFO,
+ )
+
+ gl_group = parser.add_mutually_exclusive_group(required=False)
+ gl_group.add_argument(
+ "--gles",
+ action="store_true",
+ help="Force use of GLES (AA_UseOpenGLES)",
+ )
+ gl_group.add_argument(
+ "--desktop",
+ action="store_true",
+ help="Force use of desktop OpenGL (AA_UseDesktopOpenGL)",
+ )
+ gl_group.add_argument(
+ "--software",
+ action="store_true",
+ help="Force use of software rendering(AA_UseSoftwareOpenGL)",
+ )
+ gl_group.add_argument(
+ "--disable-context-sharing",
+ action="store_true",
+ help=" Disable the use of a shared GL context for QtQuick Windows",
+ )
+
+ args = parser.parse_args()
+ apptype = args.apptype
+
+ qquick_present = False
+
+ with open(args.file) as myfile:
+ if 'import QtQuick' in myfile.read():
+ qquick_present = True
+
+ # no import QtQuick => QQCoreApplication
+ if not qquick_present:
+ apptype = "core"
+
+ import_qml_modules(args.file.parent, args.module_paths)
+
+ logging.basicConfig(level=args.loglevel)
+ logging.info(f"qml: {QLibraryInfo.build()}")
+ logging.info(f"qml: Using built-in configuration: {args.config}")
+
+ if args.rhi:
+ os.environ['QSG_RHI_BACKEND'] = args.rhi
+
+ logging.info(f"qml: loading {args.file}")
+ qml_file = QUrl.fromLocalFile(str(args.file))
+
+ if apptype == "gui":
+ if args.gles:
+ logging.info("qml: Using attribute AA_UseOpenGLES")
+ QCoreApplication.setAttribute(Qt.AA_UseOpenGLES)
+ elif args.desktop:
+ logging.info("qml: Using attribute AA_UseDesktopOpenGL")
+ QCoreApplication.setAttribute(Qt.AA_UseDesktopOpenGL)
+ elif args.software:
+ logging.info("qml: Using attribute AA_UseSoftwareOpenGL")
+ QCoreApplication.setAttribute(Qt.AA_UseSoftwareOpenGL)
+
+ # context-sharing is enabled by default
+ if not args.disable_context_sharing:
+ logging.info("qml: Using attribute AA_ShareOpenGLContexts")
+ QCoreApplication.setAttribute(Qt.AA_ShareOpenGLContexts)
+
+ if apptype == "core":
+ logging.info("qml: Core application")
+ app = QCoreApplication(sys.argv)
+ elif apptype == "widgets":
+ logging.info("qml: Widget application")
+ app = QApplication(sys.argv)
+ else:
+ logging.info("qml: Gui application")
+ app = QGuiApplication(sys.argv)
+
+ engine = QQmlApplicationEngine()
+
+ # set OpenGLContextProfile
+ if apptype == "gui" and args.core_profile:
+ logging.info("qml: Set profile for QSurfaceFormat as CoreProfile")
+ surfaceFormat = QSurfaceFormat()
+ surfaceFormat.setStencilBufferSize(8)
+ surfaceFormat.setDepthBufferSize(24)
+ surfaceFormat.setVersion(4, 1)
+ surfaceFormat.setProfile(QSurfaceFormat.CoreProfile)
+ QSurfaceFormat.setDefaultFormat(surfaceFormat)
+
+ # in the case of QCoreApplication we print the attributes of the object created via
+ # QQmlComponent and exit
+ if apptype == "core":
+ component = QQmlComponent(engine, qml_file)
+ obj = component.create()
+ filtered_attributes = {k: v for k, v in vars(obj).items() if type(v) is not SignalInstance}
+ logging.info("qml: component object attributes are")
+ pprint(filtered_attributes)
+ del engine
+ sys.exit(0)
+
+ engine.load(qml_file)
+ rootObjects = engine.rootObjects()
+ if not rootObjects:
+ sys.exit(-1)
+
+ qquick_view = False
+ if isinstance(rootObjects[0], QQuickItem) and qquick_present:
+ logging.info("qml: loading with QQuickView")
+ viewer = QQuickView()
+ viewer.setSource(qml_file)
+ if args.config != "resizeToItem":
+ viewer.setResizeMode(QQuickView.SizeRootObjectToView)
+ else:
+ viewer.setResizeMode(QQuickView.SizeViewToRootObject)
+ viewer.show()
+ qquick_view = True
+
+ if not qquick_view:
+ logging.info("qml: loading with QQmlApplicationEngine")
+ if args.config == "resizeToItem":
+ logging.info("qml: Not a QQuickview item. resizeToItem is done by default")
+
+ sys.exit(app.exec())
diff --git a/sources/pyside-tools/qtpy2cpp.py b/sources/pyside-tools/qtpy2cpp.py
new file mode 100644
index 000000000..857b12b67
--- /dev/null
+++ b/sources/pyside-tools/qtpy2cpp.py
@@ -0,0 +1,62 @@
+# 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 argparse import ArgumentParser, RawTextHelpFormatter
+from pathlib import Path
+
+from qtpy2cpp_lib.visitor import ConvertVisitor
+
+DESCRIPTION = "Tool to convert Python to C++"
+
+
+def create_arg_parser(desc):
+ parser = ArgumentParser(description=desc,
+ formatter_class=RawTextHelpFormatter)
+ parser.add_argument("--debug", "-d", action="store_true",
+ help="Debug")
+ parser.add_argument("--stdout", "-s", action="store_true",
+ help="Write to stdout")
+ parser.add_argument("--force", "-f", action="store_true",
+ help="Force overwrite of existing files")
+ parser.add_argument("files", type=str, nargs="+", help="Python source file(s)")
+ return parser
+
+
+if __name__ == "__main__":
+ logging.basicConfig(level=logging.INFO)
+ logger = logging.getLogger(__name__)
+ arg_parser = create_arg_parser(DESCRIPTION)
+ args = arg_parser.parse_args()
+ ConvertVisitor.debug = args.debug
+
+ for input_file_str in args.files:
+ input_file = Path(input_file_str)
+ if not input_file.is_file():
+ logger.error(f"{input_file_str} does not exist or is not a file.")
+ sys.exit(-1)
+ file_root, ext = os.path.splitext(input_file)
+ if input_file.suffix != ".py":
+ logger.error(f"{input_file_str} does not appear to be a Python file.")
+ sys.exit(-1)
+
+ ast_tree = ConvertVisitor.create_ast(input_file_str)
+ if args.stdout:
+ sys.stdout.write(f"// Converted from {input_file.name}\n")
+ ConvertVisitor(input_file, sys.stdout).visit(ast_tree)
+ else:
+ target_file = input_file.parent / (input_file.stem + ".cpp")
+ if target_file.exists():
+ if not target_file.is_file():
+ logger.error(f"{target_file} exists and is not a file.")
+ sys.exit(-1)
+ if not args.force:
+ logger.error(f"{target_file} exists. Use -f to overwrite.")
+ sys.exit(-1)
+
+ with target_file.open("w") as file:
+ file.write(f"// Converted from {input_file.name}\n")
+ ConvertVisitor(input_file, file).visit(ast_tree)
+ logger.info(f"Wrote {target_file}.")
diff --git a/sources/pyside-tools/qtpy2cpp.pyproject b/sources/pyside-tools/qtpy2cpp.pyproject
new file mode 100644
index 000000000..a059aebca
--- /dev/null
+++ b/sources/pyside-tools/qtpy2cpp.pyproject
@@ -0,0 +1,7 @@
+{
+ "files": ["qtpy2cpp.py",
+ "qtpy2cpp_lib/formatter.py", "qtpy2cpp_lib/visitor.py", "qtpy2cpp_lib/nodedump.py",
+ "qtpy2cpp_lib/astdump.py", "qtpy2cpp_lib/tokenizer.py", "qtpy2cpp_lib/qt.py",
+ "qtpy2cpp_lib/tests/test_qtpy2cpp.py",
+ "qtpy2cpp_lib/tests/baseline/basic_test.py", "qtpy2cpp_lib/tests/baseline/uic.py"]
+}
diff --git a/sources/pyside-tools/qtpy2cpp_lib/astdump.py b/sources/pyside-tools/qtpy2cpp_lib/astdump.py
new file mode 100644
index 000000000..d92fb7589
--- /dev/null
+++ b/sources/pyside-tools/qtpy2cpp_lib/astdump.py
@@ -0,0 +1,111 @@
+# 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
+
+"""Tool to dump a Python AST"""
+
+
+import ast
+import tokenize
+from argparse import ArgumentParser, RawTextHelpFormatter
+from enum import Enum
+
+from nodedump import debug_format_node
+
+DESCRIPTION = "Tool to dump a Python AST"
+
+
+_source_lines = []
+_opt_verbose = False
+
+
+def first_non_space(s):
+ for i, c in enumerate(s):
+ if c != ' ':
+ return i
+ return 0
+
+
+class NodeType(Enum):
+ IGNORE = 1
+ PRINT_ONE_LINE = 2 # Print as a one liner, do not visit children
+ PRINT = 3 # Print with opening closing tag, visit children
+ PRINT_WITH_SOURCE = 4 # Like PRINT, but print source line above
+
+
+def get_node_type(node):
+ if isinstance(node, (ast.Load, ast.Store, ast.Delete)):
+ return NodeType.IGNORE
+ if isinstance(node, (ast.Add, ast.alias, ast.arg, ast.Eq, ast.Gt, ast.Lt,
+ ast.Mult, ast.Name, ast.NotEq, ast.NameConstant, ast.Not,
+ ast.Num, ast.Str)):
+ return NodeType.PRINT_ONE_LINE
+ if not hasattr(node, 'lineno'):
+ return NodeType.PRINT
+ if isinstance(node, (ast.Attribute)):
+ return NodeType.PRINT_ONE_LINE if isinstance(node.value, ast.Name) else NodeType.PRINT
+ return NodeType.PRINT_WITH_SOURCE
+
+
+class DumpVisitor(ast.NodeVisitor):
+ def __init__(self):
+ ast.NodeVisitor.__init__(self)
+ self._indent = 0
+ self._printed_source_lines = {-1}
+
+ def generic_visit(self, node):
+ node_type = get_node_type(node)
+ if _opt_verbose and node_type in (NodeType.IGNORE, NodeType.PRINT_ONE_LINE):
+ node_type = NodeType.PRINT
+ if node_type == NodeType.IGNORE:
+ return
+ self._indent = self._indent + 1
+ indent = ' ' * self._indent
+
+ if node_type == NodeType.PRINT_WITH_SOURCE:
+ line_number = node.lineno - 1
+ if line_number not in self._printed_source_lines:
+ self._printed_source_lines.add(line_number)
+ line = _source_lines[line_number]
+ non_space = first_non_space(line)
+ print('{:04d} {}{}'.format(line_number, '_' * non_space,
+ line[non_space:]))
+
+ if node_type == NodeType.PRINT_ONE_LINE:
+ print(indent, debug_format_node(node))
+ else:
+ print(indent, '>', debug_format_node(node))
+ ast.NodeVisitor.generic_visit(self, node)
+ print(indent, '<', type(node).__name__)
+
+ self._indent = self._indent - 1
+
+
+def parse_ast(filename):
+ node = None
+ with tokenize.open(filename) as f:
+ global _source_lines
+ source = f.read()
+ _source_lines = source.split('\n')
+ node = ast.parse(source, mode="exec")
+ return node
+
+
+def create_arg_parser(desc):
+ parser = ArgumentParser(description=desc,
+ formatter_class=RawTextHelpFormatter)
+ parser.add_argument('--verbose', '-v', action='store_true',
+ help='Verbose')
+ parser.add_argument('source', type=str, help='Python source')
+ return parser
+
+
+if __name__ == '__main__':
+ arg_parser = create_arg_parser(DESCRIPTION)
+ options = arg_parser.parse_args()
+ _opt_verbose = options.verbose
+ title = f'AST tree for {options.source}'
+ print('=' * len(title))
+ print(title)
+ print('=' * len(title))
+ tree = parse_ast(options.source)
+ DumpVisitor().visit(tree)
diff --git a/sources/pyside-tools/qtpy2cpp_lib/formatter.py b/sources/pyside-tools/qtpy2cpp_lib/formatter.py
new file mode 100644
index 000000000..9a38e803d
--- /dev/null
+++ b/sources/pyside-tools/qtpy2cpp_lib/formatter.py
@@ -0,0 +1,265 @@
+# 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
+
+"""C++ formatting helper functions and formatter class"""
+
+
+import ast
+
+from .qt import ClassFlag, qt_class_flags
+
+CLOSING = {"{": "}", "(": ")", "[": "]"} # Closing parenthesis for C++
+
+
+def _fix_function_argument_type(type, for_return):
+ """Fix function argument/return qualifiers using some heuristics for Qt."""
+ if type == "float":
+ return "double"
+ if type == "str":
+ type = "QString"
+ if not type.startswith("Q"):
+ return type
+ flags = qt_class_flags(type)
+ if flags & ClassFlag.PASS_BY_VALUE:
+ return type
+ if flags & ClassFlag.PASS_BY_CONSTREF:
+ return type if for_return else f"const {type} &"
+ if flags & ClassFlag.PASS_BY_REF:
+ return type if for_return else f"{type} &"
+ return type + " *" # Assume pointer by default
+
+
+def to_string(node):
+ """Helper to retrieve a string from the (Lists of)Name/Attribute
+ aggregated into some nodes"""
+ if isinstance(node, ast.Name):
+ return node.id
+ if isinstance(node, ast.Attribute):
+ return node.attr
+ return ''
+
+
+def format_inheritance(class_def_node):
+ """Returns inheritance specification of a class"""
+ result = ''
+ for base in class_def_node.bases:
+ name = to_string(base)
+ if name != 'object':
+ result += ', public ' if result else ' : public '
+ result += name
+ return result
+
+
+def format_for_target(target_node):
+ if isinstance(target_node, ast.Tuple): # for i,e in enumerate()
+ result = ''
+ for i, el in enumerate(target_node.elts):
+ if i > 0:
+ result += ', '
+ result += format_reference(el)
+ return result
+ return format_reference(target_node)
+
+
+def format_for_loop(f_node):
+ """Format a for loop
+ This applies some heuristics to detect:
+ 1) "for a in [1,2])" -> "for (f: {1, 2}) {"
+ 2) "for i in range(5)" -> "for (i = 0; i < 5; ++i) {"
+ 3) "for i in range(2,5)" -> "for (i = 2; i < 5; ++i) {"
+
+ TODO: Detect other cases, maybe including enumerate().
+ """
+ loop_vars = format_for_target(f_node.target)
+ result = 'for (' + loop_vars
+ if isinstance(f_node.iter, ast.Call):
+ f = format_reference(f_node.iter.func)
+ if f == 'range':
+ start = 0
+ end = -1
+ if len(f_node.iter.args) == 2:
+ start = format_literal(f_node.iter.args[0])
+ end = format_literal(f_node.iter.args[1])
+ elif len(f_node.iter.args) == 1:
+ end = format_literal(f_node.iter.args[0])
+ result += f' = {start}; {loop_vars} < {end}; ++{loop_vars}'
+ elif isinstance(f_node.iter, ast.List):
+ # Range based for over list
+ result += ': ' + format_literal_list(f_node.iter)
+ elif isinstance(f_node.iter, ast.Name):
+ # Range based for over variable
+ result += ': ' + f_node.iter.id
+ result += ') {'
+ return result
+
+
+def format_name_constant(node):
+ """Format a ast.NameConstant."""
+ if node.value is None:
+ return "nullptr"
+ return "true" if node.value else "false"
+
+
+def format_literal(node):
+ """Returns the value of number/string literals"""
+ if isinstance(node, ast.NameConstant):
+ return format_name_constant(node)
+ if isinstance(node, ast.Num):
+ return str(node.n)
+ if isinstance(node, ast.Str):
+ # Fixme: escaping
+ return f'"{node.s}"'
+ return ''
+
+
+def format_literal_list(l_node, enclosing='{'):
+ """Formats a list/tuple of number/string literals as C++ initializer list"""
+ result = enclosing
+ for i, el in enumerate(l_node.elts):
+ if i > 0:
+ result += ', '
+ result += format_literal(el)
+ result += CLOSING[enclosing]
+ return result
+
+
+def format_member(attrib_node, qualifier_in='auto'):
+ """Member access foo->member() is expressed as an attribute with
+ further nested Attributes/Names as value"""
+ n = attrib_node
+ result = ''
+ # Black magic: Guess '::' if name appears to be a class name
+ qualifier = qualifier_in
+ if qualifier_in == 'auto':
+ qualifier = '::' if n.attr[0:1].isupper() else '->'
+ while isinstance(n, ast.Attribute):
+ result = n.attr if not result else n.attr + qualifier + result
+ n = n.value
+ if isinstance(n, ast.Name) and n.id != 'self':
+ if qualifier_in == 'auto' and n.id == "Qt": # Qt namespace
+ qualifier = "::"
+ result = n.id + qualifier + result
+ return result
+
+
+def format_reference(node, qualifier='auto'):
+ """Format member reference or free item"""
+ return node.id if isinstance(node, ast.Name) else format_member(node, qualifier)
+
+
+def format_function_def_arguments(function_def_node):
+ """Formats arguments of a function definition"""
+ # Default values is a list of the last default values, expand
+ # so that indexes match
+ argument_count = len(function_def_node.args.args)
+ default_values = function_def_node.args.defaults
+ while len(default_values) < argument_count:
+ default_values.insert(0, None)
+ result = ''
+ for i, a in enumerate(function_def_node.args.args):
+ if result:
+ result += ', '
+ if a.arg != 'self':
+ if a.annotation and isinstance(a.annotation, ast.Name):
+ result += _fix_function_argument_type(a.annotation.id, False) + ' '
+ result += a.arg
+ if default_values[i]:
+ result += ' = '
+ default_value = default_values[i]
+ if isinstance(default_value, ast.Attribute):
+ result += format_reference(default_value)
+ else:
+ result += format_literal(default_value)
+ return result
+
+
+def format_start_function_call(call_node):
+ """Format a call of a free or member function"""
+ return format_reference(call_node.func) + '('
+
+
+def write_import(file, i_node):
+ """Print an import of a Qt class as #include"""
+ for alias in i_node.names:
+ if alias.name.startswith('Q'):
+ file.write(f'#include <{alias.name}>\n')
+
+
+def write_import_from(file, i_node):
+ """Print an import from Qt classes as #include sequence"""
+ # "from PySide6.QtGui import QGuiApplication" or
+ # "from PySide6 import QtGui"
+ mod = i_node.module
+ if not mod.startswith('PySide') and not mod.startswith('PyQt'):
+ return
+ dot = mod.find('.')
+ qt_module = mod[dot + 1:] + '/' if dot >= 0 else ''
+ for i in i_node.names:
+ if i.name.startswith('Q'):
+ file.write(f'#include <{qt_module}{i.name}>\n')
+
+
+class Indenter:
+ """Helper for Indentation"""
+
+ def __init__(self, output_file):
+ self._indent_level = 0
+ self._indentation = ''
+ self._output_file = output_file
+
+ def indent_string(self, string):
+ """Start a new line by a string"""
+ self._output_file.write(self._indentation)
+ self._output_file.write(string)
+
+ def indent_line(self, line):
+ """Write an indented line"""
+ self._output_file.write(self._indentation)
+ self._output_file.write(line)
+ self._output_file.write('\n')
+
+ def INDENT(self):
+ """Write indentation"""
+ self._output_file.write(self._indentation)
+
+ def indent(self):
+ """Increase indentation level"""
+ self._indent_level = self._indent_level + 1
+ self._indentation = ' ' * self._indent_level
+
+ def dedent(self):
+ """Decrease indentation level"""
+ self._indent_level = self._indent_level - 1
+ self._indentation = ' ' * self._indent_level
+
+
+class CppFormatter(Indenter):
+ """Provides helpers for formatting multi-line C++ constructs"""
+
+ def __init__(self, output_file):
+ Indenter.__init__(self, output_file)
+
+ def write_class_def(self, class_node):
+ """Print a class definition with inheritance"""
+ self._output_file.write('\n')
+ inherits = format_inheritance(class_node)
+ self.indent_line(f'class {class_node.name}{inherits}')
+ self.indent_line('{')
+ self.indent_line('public:')
+
+ def write_function_def(self, f_node, class_context):
+ """Print a function definition with arguments"""
+ self._output_file.write('\n')
+ arguments = format_function_def_arguments(f_node)
+ if f_node.name == '__init__' and class_context: # Constructor
+ name = class_context
+ elif f_node.name == '__del__' and class_context: # Destructor
+ name = '~' + class_context
+ else:
+ return_type = "void"
+ if f_node.returns and isinstance(f_node.returns, ast.Name):
+ return_type = _fix_function_argument_type(f_node.returns.id, True)
+ name = return_type + " " + f_node.name
+ self.indent_string(f'{name}({arguments})')
+ self._output_file.write('\n')
+ self.indent_line('{')
diff --git a/sources/pyside-tools/qtpy2cpp_lib/nodedump.py b/sources/pyside-tools/qtpy2cpp_lib/nodedump.py
new file mode 100644
index 000000000..de62e9700
--- /dev/null
+++ b/sources/pyside-tools/qtpy2cpp_lib/nodedump.py
@@ -0,0 +1,50 @@
+# 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
+
+"""Helper to dump AST nodes for debugging"""
+
+
+import ast
+
+
+def to_string(node):
+ """Helper to retrieve a string from the (Lists of )Name/Attribute
+ aggregated into some nodes"""
+ if isinstance(node, ast.Name):
+ return node.id
+ if isinstance(node, ast.Attribute):
+ return node.attr
+ return ''
+
+
+def debug_format_node(node):
+ """Format AST node for debugging"""
+ if isinstance(node, ast.alias):
+ return f'alias("{node.name}")'
+ if isinstance(node, ast.arg):
+ return f'arg({node.arg})'
+ if isinstance(node, ast.Attribute):
+ if isinstance(node.value, ast.Name):
+ nested_name = debug_format_node(node.value)
+ return f'Attribute("{node.attr}", {nested_name})'
+ return f'Attribute("{node.attr}")'
+ if isinstance(node, ast.Call):
+ return 'Call({}({}))'.format(to_string(node.func), len(node.args))
+ if isinstance(node, ast.ClassDef):
+ base_names = [to_string(base) for base in node.bases]
+ bases = ': ' + ','.join(base_names) if base_names else ''
+ return f'ClassDef({node.name}{bases})'
+ if isinstance(node, ast.ImportFrom):
+ return f'ImportFrom("{node.module}")'
+ if isinstance(node, ast.FunctionDef):
+ arg_names = [a.arg for a in node.args.args]
+ return 'FunctionDef({}({}))'.format(node.name, ', '.join(arg_names))
+ if isinstance(node, ast.Name):
+ return 'Name("{}", Ctx={})'.format(node.id, type(node.ctx).__name__)
+ if isinstance(node, ast.NameConstant):
+ return f'NameConstant({node.value})'
+ if isinstance(node, ast.Num):
+ return f'Num({node.n})'
+ if isinstance(node, ast.Str):
+ return f'Str("{node.s}")'
+ return type(node).__name__
diff --git a/sources/pyside-tools/qtpy2cpp_lib/qt.py b/sources/pyside-tools/qtpy2cpp_lib/qt.py
new file mode 100644
index 000000000..69bd54aeb
--- /dev/null
+++ b/sources/pyside-tools/qtpy2cpp_lib/qt.py
@@ -0,0 +1,56 @@
+# 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
+
+"""Provides some type information on Qt classes"""
+
+
+from enum import Flag
+
+
+class ClassFlag(Flag):
+ PASS_BY_CONSTREF = 1
+ PASS_BY_REF = 2
+ PASS_BY_VALUE = 4
+ PASS_ON_STACK_MASK = PASS_BY_CONSTREF | PASS_BY_REF | PASS_BY_VALUE
+ INSTANTIATE_ON_STACK = 8
+
+
+_QT_CLASS_FLAGS = {
+ "QBrush": ClassFlag.PASS_BY_CONSTREF | ClassFlag.INSTANTIATE_ON_STACK,
+ "QGradient": ClassFlag.PASS_BY_CONSTREF | ClassFlag.INSTANTIATE_ON_STACK,
+ "QIcon": ClassFlag.PASS_BY_CONSTREF | ClassFlag.INSTANTIATE_ON_STACK,
+ "QLine": ClassFlag.PASS_BY_CONSTREF | ClassFlag.INSTANTIATE_ON_STACK,
+ "QLineF": ClassFlag.PASS_BY_CONSTREF | ClassFlag.INSTANTIATE_ON_STACK,
+ "QPixmap": ClassFlag.PASS_BY_CONSTREF | ClassFlag.INSTANTIATE_ON_STACK,
+ "QPointF": ClassFlag.PASS_BY_CONSTREF | ClassFlag.INSTANTIATE_ON_STACK,
+ "QRect": ClassFlag.PASS_BY_CONSTREF | ClassFlag.INSTANTIATE_ON_STACK,
+ "QRectF": ClassFlag.PASS_BY_CONSTREF | ClassFlag.INSTANTIATE_ON_STACK,
+ "QSizeF": ClassFlag.PASS_BY_CONSTREF | ClassFlag.INSTANTIATE_ON_STACK,
+ "QString": ClassFlag.PASS_BY_CONSTREF | ClassFlag.INSTANTIATE_ON_STACK,
+ "QFile": ClassFlag.PASS_BY_REF | ClassFlag.INSTANTIATE_ON_STACK,
+ "QSettings": ClassFlag.PASS_BY_REF | ClassFlag.INSTANTIATE_ON_STACK,
+ "QTextStream": ClassFlag.PASS_BY_REF | ClassFlag.INSTANTIATE_ON_STACK,
+ "QColor": ClassFlag.PASS_BY_VALUE | ClassFlag.INSTANTIATE_ON_STACK,
+ "QPoint": ClassFlag.PASS_BY_VALUE | ClassFlag.INSTANTIATE_ON_STACK,
+ "QSize": ClassFlag.PASS_BY_VALUE | ClassFlag.INSTANTIATE_ON_STACK,
+ "QApplication": ClassFlag.INSTANTIATE_ON_STACK,
+ "QColorDialog": ClassFlag.INSTANTIATE_ON_STACK,
+ "QCoreApplication": ClassFlag.INSTANTIATE_ON_STACK,
+ "QFileDialog": ClassFlag.INSTANTIATE_ON_STACK,
+ "QFileInfo": ClassFlag.INSTANTIATE_ON_STACK,
+ "QFontDialog": ClassFlag.INSTANTIATE_ON_STACK,
+ "QGuiApplication": ClassFlag.INSTANTIATE_ON_STACK,
+ "QMessageBox": ClassFlag.INSTANTIATE_ON_STACK,
+ "QPainter": ClassFlag.INSTANTIATE_ON_STACK,
+ "QPen": ClassFlag.INSTANTIATE_ON_STACK,
+ "QQmlApplicationEngine": ClassFlag.INSTANTIATE_ON_STACK,
+ "QQmlComponent": ClassFlag.INSTANTIATE_ON_STACK,
+ "QQmlEngine": ClassFlag.INSTANTIATE_ON_STACK,
+ "QQuickView": ClassFlag.INSTANTIATE_ON_STACK,
+ "QSaveFile": ClassFlag.INSTANTIATE_ON_STACK
+}
+
+
+def qt_class_flags(type):
+ f = _QT_CLASS_FLAGS.get(type)
+ return f if f else ClassFlag(0)
diff --git a/sources/pyside-tools/qtpy2cpp_lib/tests/baseline/basic_test.cpp b/sources/pyside-tools/qtpy2cpp_lib/tests/baseline/basic_test.cpp
new file mode 100644
index 000000000..8ee7be31e
--- /dev/null
+++ b/sources/pyside-tools/qtpy2cpp_lib/tests/baseline/basic_test.cpp
@@ -0,0 +1,62 @@
+// Copyright (C) 2022 The Qt Company Ltd.
+// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR GPL-3.0-only WITH Qt-GPL-exception-1.0
+
+// Converted from basic_test.py
+#include <QtCore/Qt>
+#include <QtGui/QColor>
+#include <QtGui/QPainter>
+#include <QtGui/QPaintEvent>
+#include <QtGui/QShortcut>
+#include <QtWidgets/QApplication>
+#include <QtWidgets/QWidget>
+
+class Window : public QWidget
+{
+public:
+
+ Window(QWidget * parent = nullptr)
+ {
+ super()->__init__(parent);
+ }
+
+ void paintEvent(QPaintEvent * e)
+ {
+ paint("bla");
+ }
+
+ void paint(const QString & what, color = Qt::blue)
+ {
+ { // Converted from context manager
+ p = QPainter();
+ p->setPen(QColor(color));
+ rect = rect();
+ w = rect->width();
+ h = rect->height();
+ p->drawLine(0, 0, w, h);
+ p->drawLine(0, h, w, 0);
+ p->drawText(rect->center(), what);
+ }
+ }
+
+ void sum()
+ {
+ values = {1, 2, 3};
+ result = 0;
+ for (v: values) {
+ result += v
+ }
+ return result;
+ }
+};
+
+int main(int argc, char *argv[])
+{
+ QApplication app(sys->argv);
+ window = Window();
+ auto *sc = new QShortcut((Qt::CTRL | Qt::Key_Q), window);
+ sc->activated->connect(window->close);
+ window->setWindowTitle("Test");
+ window->show();
+ sys->exit(app.exec());
+ return 0;
+}
diff --git a/sources/pyside-tools/qtpy2cpp_lib/tests/baseline/basic_test.py b/sources/pyside-tools/qtpy2cpp_lib/tests/baseline/basic_test.py
new file mode 100644
index 000000000..1466ac6b1
--- /dev/null
+++ b/sources/pyside-tools/qtpy2cpp_lib/tests/baseline/basic_test.py
@@ -0,0 +1,44 @@
+#!/usr/bin/env python
+# Copyright (C) 2022 The Qt Company Ltd.
+# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR GPL-3.0-only WITH Qt-GPL-exception-1.0
+
+import sys
+
+from PySide6.QtCore import Qt
+from PySide6.QtGui import QColor, QPainter, QPaintEvent, QShortcut
+from PySide6.QtWidgets import QApplication, QWidget
+
+
+class Window(QWidget):
+ def __init__(self, parent: QWidget = None):
+ super().__init__(parent)
+
+ def paintEvent(self, e: QPaintEvent):
+ self.paint("bla")
+
+ def paint(self, what: str, color: Qt.GlobalColor = Qt.blue):
+ with QPainter(self) as p:
+ p.setPen(QColor(color))
+ rect = self.rect()
+ w = rect.width()
+ h = rect.height()
+ p.drawLine(0, 0, w, h)
+ p.drawLine(0, h, w, 0)
+ p.drawText(rect.center(), what)
+
+ def sum(self):
+ values = [1, 2, 3]
+ result = 0
+ for v in values:
+ result += v
+ return result
+
+
+if __name__ == '__main__':
+ app = QApplication(sys.argv)
+ window = Window()
+ sc = QShortcut(Qt.CTRL | Qt.Key_Q, window)
+ sc.activated.connect(window.close)
+ window.setWindowTitle("Test")
+ window.show()
+ sys.exit(app.exec())
diff --git a/sources/pyside-tools/qtpy2cpp_lib/tests/test_qtpy2cpp.py b/sources/pyside-tools/qtpy2cpp_lib/tests/test_qtpy2cpp.py
new file mode 100644
index 000000000..894b2a958
--- /dev/null
+++ b/sources/pyside-tools/qtpy2cpp_lib/tests/test_qtpy2cpp.py
@@ -0,0 +1,54 @@
+# 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 subprocess
+import tempfile
+import sys
+from pathlib import Path
+
+# run pytest-3
+
+
+def diff_code(actual_code, expected_file):
+ """Helper to run diff if something fails (Linux only)."""
+ with tempfile.NamedTemporaryFile(suffix=".cpp") as tf:
+ tf.write(actual_code.encode('utf-8'))
+ tf.flush()
+ diff_cmd = ["diff", "-u", expected_file, tf.name]
+ subprocess.run(diff_cmd)
+
+
+def run_converter(tool, file):
+ """Run the converter and return C++ code generated from file."""
+ cmd = [sys.executable, tool, "--stdout", file]
+ output = ""
+ with subprocess.Popen(cmd, stdout=subprocess.PIPE) as proc:
+ output_b, errors_b = proc.communicate()
+ output = output_b.decode('utf-8')
+ if errors_b:
+ print(errors_b.decode('utf-8'), file=sys.stderr)
+ return output
+
+
+def test_examples():
+ dir = Path(__file__).resolve().parent
+ tool = dir.parents[1] / "qtpy2cpp.py"
+ assert tool.is_file
+ for test_file in (dir / "baseline").glob("*.py"):
+ assert test_file.is_file
+ expected_file = test_file.parent / (test_file.stem + ".cpp")
+ if expected_file.is_file():
+ actual_code = run_converter(tool, test_file)
+ assert actual_code
+ expected_code = expected_file.read_text()
+ # Strip the license
+ code_start = expected_code.find("// Converted from")
+ assert code_start != -1
+ expected_code = expected_code[code_start:]
+
+ if actual_code != expected_code:
+ diff_code(actual_code, expected_file)
+ assert actual_code == expected_code
+ else:
+ print(f"Warning, {test_file} is missing a .cpp file.",
+ file=sys.stderr)
diff --git a/sources/pyside-tools/qtpy2cpp_lib/tokenizer.py b/sources/pyside-tools/qtpy2cpp_lib/tokenizer.py
new file mode 100644
index 000000000..d5e26c2a8
--- /dev/null
+++ b/sources/pyside-tools/qtpy2cpp_lib/tokenizer.py
@@ -0,0 +1,55 @@
+# 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
+
+"""Tool to dump Python Tokens"""
+
+
+import sys
+import tokenize
+
+
+def format_token(t):
+ r = repr(t)
+ if r.startswith('TokenInfo('):
+ r = r[10:]
+ pos = r.find("), line='")
+ if pos < 0:
+ pos = r.find('), line="')
+ if pos > 0:
+ r = r[:pos + 1]
+ return r
+
+
+def first_non_space(s):
+ for i, c in enumerate(s):
+ if c != ' ':
+ return i
+ return 0
+
+
+if __name__ == '__main__':
+ if len(sys.argv) < 2:
+ print("Specify file Name")
+ sys.exit(1)
+ filename = sys.argv[1]
+ indent_level = 0
+ indent = ''
+ last_line_number = -1
+ with tokenize.open(filename) as f:
+ generator = tokenize.generate_tokens(f.readline)
+ for t in generator:
+ line_number = t.start[0]
+ if line_number != last_line_number:
+ code_line = t.line.rstrip()
+ non_space = first_non_space(code_line)
+ print('{:04d} {}{}'.format(line_number, '_' * non_space,
+ code_line[non_space:]))
+ last_line_number = line_number
+ if t.type == tokenize.INDENT:
+ indent_level = indent_level + 1
+ indent = ' ' * indent_level
+ elif t.type == tokenize.DEDENT:
+ indent_level = indent_level - 1
+ indent = ' ' * indent_level
+ else:
+ print(' ', indent, format_token(t))
diff --git a/sources/pyside-tools/qtpy2cpp_lib/visitor.py b/sources/pyside-tools/qtpy2cpp_lib/visitor.py
new file mode 100644
index 000000000..2056951ae
--- /dev/null
+++ b/sources/pyside-tools/qtpy2cpp_lib/visitor.py
@@ -0,0 +1,442 @@
+# 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
+
+"""AST visitor printing out C++"""
+
+import ast
+import sys
+import tokenize
+import warnings
+
+from .formatter import (CppFormatter, format_for_loop, format_literal,
+ format_name_constant,
+ format_reference, write_import, write_import_from)
+from .nodedump import debug_format_node
+from .qt import ClassFlag, qt_class_flags
+
+
+def _is_qt_constructor(assign_node):
+ """Is this assignment node a plain construction of a Qt class?
+ 'f = QFile(name)'. Returns the class_name."""
+ call = assign_node.value
+ if (isinstance(call, ast.Call) and isinstance(call.func, ast.Name)):
+ func = call.func.id
+ if func.startswith("Q"):
+ return func
+ return None
+
+
+def _is_if_main(if_node):
+ """Return whether an if statement is: if __name__ == '__main__' """
+ test = if_node.test
+ return (isinstance(test, ast.Compare)
+ and len(test.ops) == 1
+ and isinstance(test.ops[0], ast.Eq)
+ and isinstance(test.left, ast.Name)
+ and test.left.id == "__name__"
+ and len(test.comparators) == 1
+ and isinstance(test.comparators[0], ast.Constant)
+ and test.comparators[0].value == "__main__")
+
+
+class ConvertVisitor(ast.NodeVisitor, CppFormatter):
+ """AST visitor printing out C++
+ Note on implementation:
+ - Any visit_XXX() overridden function should call self.generic_visit(node)
+ to continue visiting
+ - When controlling the visiting manually (cf visit_Call()),
+ self.visit(child) needs to be called since that dispatches to
+ visit_XXX(). This is usually done to prevent undesired output
+ for example from references of calls, etc.
+ """
+
+ debug = False
+
+ def __init__(self, file_name, output_file):
+ ast.NodeVisitor.__init__(self)
+ CppFormatter.__init__(self, output_file)
+ self._file_name = file_name
+ self._class_scope = [] # List of class names
+ self._stack = [] # nodes
+ self._stack_variables = [] # variables instantiated on stack
+ self._debug_indent = 0
+
+ @staticmethod
+ def create_ast(filename):
+ """Create an Abstract Syntax Tree on which a visitor can be run"""
+ node = None
+ with tokenize.open(filename) as file:
+ node = ast.parse(file.read(), mode="exec")
+ return node
+
+ def generic_visit(self, node):
+ parent = self._stack[-1] if self._stack else None
+ if self.debug:
+ self._debug_enter(node, parent)
+ self._stack.append(node)
+ try:
+ super().generic_visit(node)
+ except Exception as e:
+ line_no = node.lineno if hasattr(node, 'lineno') else -1
+ error_message = str(e)
+ message = f'{self._file_name}:{line_no}: Error "{error_message}"'
+ warnings.warn(message)
+ self._output_file.write(f'\n// {error_message}\n')
+ del self._stack[-1]
+ if self.debug:
+ self._debug_leave(node)
+
+ def visit_Add(self, node):
+ self._handle_bin_op(node, "+")
+
+ def _is_augmented_assign(self):
+ """Is it 'Augmented_assign' (operators +=/-=, etc)?"""
+ return self._stack and isinstance(self._stack[-1], ast.AugAssign)
+
+ def visit_AugAssign(self, node):
+ """'Augmented_assign', Operators +=/-=, etc."""
+ self.INDENT()
+ self.generic_visit(node)
+ self._output_file.write("\n")
+
+ def visit_Assign(self, node):
+ self.INDENT()
+
+ qt_class = _is_qt_constructor(node)
+ on_stack = qt_class and qt_class_flags(qt_class) & ClassFlag.INSTANTIATE_ON_STACK
+
+ # Is this a free variable and not a member assignment? Instantiate
+ # on stack or give a type
+ if len(node.targets) == 1 and isinstance(node.targets[0], ast.Name):
+ if qt_class:
+ if on_stack:
+ # "QFile f(args)"
+ var = node.targets[0].id
+ self._stack_variables.append(var)
+ self._output_file.write(f"{qt_class} {var}(")
+ self._write_function_args(node.value.args)
+ self._output_file.write(");\n")
+ return
+ self._output_file.write("auto *")
+
+ line_no = node.lineno if hasattr(node, 'lineno') else -1
+ for target in node.targets:
+ if isinstance(target, ast.Tuple):
+ w = f"{self._file_name}:{line_no}: List assignment not handled."
+ warnings.warn(w)
+ elif isinstance(target, ast.Subscript):
+ w = f"{self._file_name}:{line_no}: Subscript assignment not handled."
+ warnings.warn(w)
+ else:
+ self._output_file.write(format_reference(target))
+ self._output_file.write(' = ')
+ if qt_class and not on_stack:
+ self._output_file.write("new ")
+ self.visit(node.value)
+ self._output_file.write(';\n')
+
+ def visit_Attribute(self, node):
+ """Format a variable reference (cf visit_Name)"""
+ # Default parameter (like Qt::black)?
+ if self._ignore_function_def_node(node):
+ return
+ self._output_file.write(format_reference(node))
+
+ def visit_BinOp(self, node):
+ # Parentheses are not exposed, so, every binary operation needs to
+ # be enclosed by ().
+ self._output_file.write('(')
+ self.generic_visit(node)
+ self._output_file.write(')')
+
+ def _handle_bin_op(self, node, op):
+ """Handle a binary operator which can appear as 'Augmented Assign'."""
+ self.generic_visit(node)
+ full_op = f" {op}= " if self._is_augmented_assign() else f" {op} "
+ self._output_file.write(full_op)
+
+ def visit_BitAnd(self, node):
+ self._handle_bin_op(node, "&")
+
+ def visit_BitOr(self, node):
+ self._handle_bin_op(node, "|")
+
+ def _format_call(self, node):
+ # Decorator list?
+ if self._ignore_function_def_node(node):
+ return
+ f = node.func
+ if isinstance(f, ast.Name):
+ self._output_file.write(f.id)
+ else:
+ # Attributes denoting chained calls "a->b()->c()". Walk along in
+ # reverse order, recursing for other calls.
+ names = []
+ n = f
+ while isinstance(n, ast.Attribute):
+ names.insert(0, n.attr)
+ n = n.value
+
+ if isinstance(n, ast.Name): # Member or variable reference
+ if n.id != "self":
+ sep = "->"
+ if n.id in self._stack_variables:
+ sep = "."
+ elif n.id[0:1].isupper(): # Heuristics for static
+ sep = "::"
+ self._output_file.write(n.id)
+ self._output_file.write(sep)
+ elif isinstance(n, ast.Call): # A preceding call
+ self._format_call(n)
+ self._output_file.write("->")
+
+ self._output_file.write("->".join(names))
+
+ self._output_file.write('(')
+ self._write_function_args(node.args)
+ self._output_file.write(')')
+
+ def visit_Call(self, node):
+ self._format_call(node)
+ # Context manager expression?
+ if self._within_context_manager():
+ self._output_file.write(";\n")
+
+ def _write_function_args(self, args_node):
+ # Manually do visit(), skip the children of func
+ for i, arg in enumerate(args_node):
+ if i > 0:
+ self._output_file.write(', ')
+ self.visit(arg)
+
+ def visit_ClassDef(self, node):
+ # Manually do visit() to skip over base classes
+ # and annotations
+ self._class_scope.append(node.name)
+ self.write_class_def(node)
+ self.indent()
+ for b in node.body:
+ self.visit(b)
+ self.dedent()
+ self.indent_line('};')
+ del self._class_scope[-1]
+
+ def visit_Div(self, node):
+ self._handle_bin_op(node, "/")
+
+ def visit_Eq(self, node):
+ self.generic_visit(node)
+ self._output_file.write(" == ")
+
+ def visit_Expr(self, node):
+ self.INDENT()
+ self.generic_visit(node)
+ self._output_file.write(';\n')
+
+ def visit_Gt(self, node):
+ self.generic_visit(node)
+ self._output_file.write(" > ")
+
+ def visit_GtE(self, node):
+ self.generic_visit(node)
+ self._output_file.write(" >= ")
+
+ def visit_For(self, node):
+ # Manually do visit() to get the indentation right.
+ # TODO: what about orelse?
+ self.indent_line(format_for_loop(node))
+ self.indent()
+ for b in node.body:
+ self.visit(b)
+ self.dedent()
+ self.indent_line('}')
+
+ def visit_FunctionDef(self, node):
+ class_context = self._class_scope[-1] if self._class_scope else None
+ for decorator in node.decorator_list:
+ func = decorator.func # (Call)
+ if isinstance(func, ast.Name) and func.id == "Slot":
+ self._output_file.write("\npublic slots:")
+ self.write_function_def(node, class_context)
+ # Find stack variables
+ for arg in node.args.args:
+ if arg.annotation and isinstance(arg.annotation, ast.Name):
+ type_name = arg.annotation.id
+ flags = qt_class_flags(type_name)
+ if flags & ClassFlag.PASS_ON_STACK_MASK:
+ self._stack_variables.append(arg.arg)
+ self.indent()
+ self.generic_visit(node)
+ self.dedent()
+ self.indent_line('}')
+ self._stack_variables.clear()
+
+ def visit_If(self, node):
+ # Manually do visit() to get the indentation right. Note:
+ # elsif() is modelled as nested if.
+
+ # Check for the main function
+ if _is_if_main(node):
+ self._output_file.write("\nint main(int argc, char *argv[])\n{\n")
+ self.indent()
+ for b in node.body:
+ self.visit(b)
+ self.indent_string("return 0;\n")
+ self.dedent()
+ self._output_file.write("}\n")
+ return
+
+ self.indent_string('if (')
+ self.visit(node.test)
+ self._output_file.write(') {\n')
+ self.indent()
+ for b in node.body:
+ self.visit(b)
+ self.dedent()
+ self.indent_string('}')
+ if node.orelse:
+ self._output_file.write(' else {\n')
+ self.indent()
+ for b in node.orelse:
+ self.visit(b)
+ self.dedent()
+ self.indent_string('}')
+ self._output_file.write('\n')
+
+ def visit_Import(self, node):
+ write_import(self._output_file, node)
+
+ def visit_ImportFrom(self, node):
+ write_import_from(self._output_file, node)
+
+ def visit_List(self, node):
+ # Manually do visit() to get separators right
+ self._output_file.write('{')
+ for i, el in enumerate(node.elts):
+ if i > 0:
+ self._output_file.write(', ')
+ self.visit(el)
+ self._output_file.write('}')
+
+ def visit_LShift(self, node):
+ self.generic_visit(node)
+ self._output_file.write(" << ")
+
+ def visit_Lt(self, node):
+ self.generic_visit(node)
+ self._output_file.write(" < ")
+
+ def visit_LtE(self, node):
+ self.generic_visit(node)
+ self._output_file.write(" <= ")
+
+ def visit_Mult(self, node):
+ self._handle_bin_op(node, "*")
+
+ def _within_context_manager(self):
+ """Return whether we are within a context manager (with)."""
+ parent = self._stack[-1] if self._stack else None
+ return parent and isinstance(parent, ast.withitem)
+
+ def _ignore_function_def_node(self, node):
+ """Should this node be ignored within a FunctionDef."""
+ if not self._stack:
+ return False
+ parent = self._stack[-1]
+ # A type annotation or default value of an argument?
+ if isinstance(parent, (ast.arguments, ast.arg)):
+ return True
+ if not isinstance(parent, ast.FunctionDef):
+ return False
+ # Return type annotation or decorator call
+ return node == parent.returns or node in parent.decorator_list
+
+ def visit_Index(self, node):
+ self._output_file.write("[")
+ self.generic_visit(node)
+ self._output_file.write("]")
+
+ def visit_Name(self, node):
+ """Format a variable reference (cf visit_Attribute)"""
+ # Skip Context manager variables, return or argument type annotation
+ if self._within_context_manager() or self._ignore_function_def_node(node):
+ return
+ self._output_file.write(format_reference(node))
+
+ def visit_NameConstant(self, node):
+ # Default parameter?
+ if self._ignore_function_def_node(node):
+ return
+ self.generic_visit(node)
+ self._output_file.write(format_name_constant(node))
+
+ def visit_Not(self, node):
+ self.generic_visit(node)
+ self._output_file.write("!")
+
+ def visit_NotEq(self, node):
+ self.generic_visit(node)
+ self._output_file.write(" != ")
+
+ def visit_Num(self, node):
+ self.generic_visit(node)
+ self._output_file.write(format_literal(node))
+
+ def visit_RShift(self, node):
+ self.generic_visit(node)
+ self._output_file.write(" >> ")
+
+ def visit_Return(self, node):
+ self.indent_string("return")
+ if node.value:
+ self._output_file.write(" ")
+ self.generic_visit(node)
+ self._output_file.write(";\n")
+
+ def visit_Slice(self, node):
+ self._output_file.write("[")
+ if node.lower:
+ self.visit(node.lower)
+ self._output_file.write(":")
+ if node.upper:
+ self.visit(node.upper)
+ self._output_file.write("]")
+
+ def visit_Str(self, node):
+ self.generic_visit(node)
+ self._output_file.write(format_literal(node))
+
+ def visit_Sub(self, node):
+ self._handle_bin_op(node, "-")
+
+ def visit_UnOp(self, node):
+ self.generic_visit(node)
+
+ def visit_With(self, node):
+ self.INDENT()
+ self._output_file.write("{ // Converted from context manager\n")
+ self.indent()
+ for item in node.items:
+ self.INDENT()
+ if item.optional_vars:
+ self._output_file.write(format_reference(item.optional_vars))
+ self._output_file.write(" = ")
+ self.generic_visit(node)
+ self.dedent()
+ self.INDENT()
+ self._output_file.write("}\n")
+
+ def _debug_enter(self, node, parent=None):
+ message = '{}>generic_visit({})'.format(' ' * self ._debug_indent,
+ debug_format_node(node))
+ if parent:
+ message += ', parent={}'.format(debug_format_node(parent))
+ message += '\n'
+ sys.stderr.write(message)
+ self._debug_indent += 1
+
+ def _debug_leave(self, node):
+ self._debug_indent -= 1
+ message = '{}<generic_visit({})\n'.format(' ' * self ._debug_indent,
+ type(node).__name__)
+ sys.stderr.write(message)
diff --git a/sources/pyside-tools/requirements-android.txt b/sources/pyside-tools/requirements-android.txt
new file mode 100644
index 000000000..1169fd663
--- /dev/null
+++ b/sources/pyside-tools/requirements-android.txt
@@ -0,0 +1,2 @@
+jinja2
+pkginfo