diff options
Diffstat (limited to 'tools')
-rw-r--r-- | tools/create_changelog.py | 70 | ||||
-rw-r--r-- | tools/cross_compile_android/android_utilities.py | 297 | ||||
-rw-r--r-- | tools/cross_compile_android/main.py | 309 | ||||
-rw-r--r-- | tools/cross_compile_android/requirements.txt | 3 | ||||
-rw-r--r-- | tools/cross_compile_android/templates/cross_compile.tmpl.sh | 29 | ||||
-rw-r--r-- | tools/cross_compile_android/templates/toolchain_default.tmpl.cmake | 73 | ||||
-rw-r--r-- | tools/doc_modules.py | 5 | ||||
-rw-r--r-- | tools/example_gallery/main.py | 670 | ||||
-rw-r--r-- | tools/missing_bindings-requirements.txt | 7 | ||||
-rw-r--r-- | tools/missing_bindings/config.py | 28 | ||||
-rw-r--r-- | tools/missing_bindings/main.py | 121 | ||||
-rw-r--r-- | tools/missing_bindings/requirements.txt | 2 | ||||
-rw-r--r-- | tools/scanqtclasses.py | 122 | ||||
-rw-r--r-- | tools/snippets_translate/converter.py | 41 | ||||
-rw-r--r-- | tools/snippets_translate/handlers.py | 35 | ||||
-rw-r--r-- | tools/snippets_translate/main.py | 16 | ||||
-rw-r--r-- | tools/snippets_translate/module_classes.py | 1 | ||||
-rw-r--r-- | tools/snippets_translate/tests/test_converter.py | 64 | ||||
-rw-r--r-- | tools/snippets_translate/tests/test_snippets.py | 34 |
19 files changed, 1684 insertions, 243 deletions
diff --git a/tools/create_changelog.py b/tools/create_changelog.py index 2fdc2743f..6c24f417f 100644 --- a/tools/create_changelog.py +++ b/tools/create_changelog.py @@ -2,9 +2,11 @@ # SPDX-License-Identifier: LicenseRef-Qt-Commercial OR LGPL-3.0-only OR GPL-2.0-only OR GPL-3.0-only import re +import os import sys import textwrap from argparse import ArgumentParser, Namespace, RawTextHelpFormatter +from pathlib import Path from subprocess import PIPE, Popen, check_output from typing import Dict, List, Tuple @@ -37,10 +39,45 @@ description = """ PySide6 changelog tool Example usage: -tools/create_changelog.py -v v6.2.3..HEAD -r 6.2.4 +tools/create_changelog.py -v -r 6.5.3 """ +def change_log(version: list) -> Path: + """Return path of the changelog of the version.""" + name = f"changes-{version[0]}.{version[1]}.{version[2]}" + return Path(__file__).parents[1] / "doc" / "changelogs" / name + + +def is_lts_version(version: list) -> bool: + return version[0] == 5 or version[1] in (2, 5) + + +def version_tag(version: list) -> str: + """Return the version tag.""" + tag = f"v{version[0]}.{version[1]}.{version[2]}" + return tag + "-lts" if is_lts_version(version) else tag + + +def revision_range(version: list) -> str: + """Determine a git revision_range from the version. Either log from + the previous version tag or since the last update to the changelog.""" + changelog = change_log(version) + if changelog.is_file(): + output = check_output(["git", "log", "-n", "1", "--format=%H", + os.fspath(changelog)]) + if output: + return output.strip().decode("utf-8") + "..HEAD" + + last_version = version.copy() + if version[2] == 0: + adjust_idx = 0 if version[1] == 0 else 1 + else: + adjust_idx = 2 + last_version[adjust_idx] -= 1 + return version_tag(last_version) + "..HEAD" + + def parse_options() -> Namespace: tag_msg = ("Tags, branches, or SHA to compare\n" "e.g.: v5.12.1..5.12\n" @@ -56,8 +93,7 @@ def parse_options() -> Namespace: options.add_argument("-v", "--versions", type=str, - help=tag_msg, - required=True) + help=tag_msg) options.add_argument("-r", "--release", type=str, @@ -66,8 +102,7 @@ def parse_options() -> Namespace: options.add_argument("-t", "--type", type=str, - help="Release type: bug-fix, minor, or major", - default="bug-fix") + help="Release type: bug-fix, minor, or major") options.add_argument("-e", "--exclude", @@ -76,11 +111,34 @@ def parse_options() -> Namespace: default=False) args = options.parse_args() + + release_version = list(int(v) for v in args.release.split(".")) + if len(release_version) != 3: + print("Error: --release must be of form major.minor.patch") + sys.exit(-1) + + # Some auto-detection smartness + if not args.type: + if release_version[2] == 0: + args.type = "major" if release_version[1] == 0 else "minor" + else: + args.type = "bug-fix" + # For major/minor releases, skip all fixes with "Pick-to: " since they + # appear in bug-fix releases. + if args.type != "bug-fix": + args.exclude = True + print(f'Assuming "{args.type}" version', file=sys.stderr) + if args.type not in ("bug-fix", "minor", "major"): - print("Error:" + print("Error: " "-y/--type needs to be: bug-fix (default), minor, or major") sys.exit(-1) + if not args.versions: + args.versions = revision_range(release_version) + print(f"Assuming range {args.versions}", file=sys.stderr) + + args.release_version = release_version return args diff --git a/tools/cross_compile_android/android_utilities.py b/tools/cross_compile_android/android_utilities.py new file mode 100644 index 000000000..3d93abec2 --- /dev/null +++ b/tools/cross_compile_android/android_utilities.py @@ -0,0 +1,297 @@ +# 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 re +import os +import stat +import sys +import subprocess + +from urllib import request +from pathlib import Path +from typing import List +from packaging import version +from tqdm import tqdm + +# the tag number does not matter much since we update the sdk later +DEFAULT_SDK_TAG = 6514223 +ANDROID_NDK_VERSION = "26b" +ANDROID_NDK_VERSION_NUMBER_SUFFIX = "10909125" + + +def run_command(command: List[str], cwd: str = None, ignore_fail: bool = False, + dry_run: bool = False, accept_prompts: bool = False, show_stdout: bool = False, + capture_stdout: bool = False): + + if capture_stdout and not show_stdout: + raise RuntimeError("capture_stdout should always be used together with show_stdout") + + if dry_run: + print(" ".join(command)) + return + + input = None + if accept_prompts: + input = str.encode("y") + + if show_stdout: + stdout = None + else: + stdout = subprocess.DEVNULL + + result = subprocess.run(command, cwd=cwd, input=input, stdout=stdout, + capture_output=capture_stdout) + + if result.returncode != 0 and not ignore_fail: + sys.exit(result.returncode) + + if capture_stdout and not result.returncode: + return result.stdout.decode("utf-8") + + return None + + +class DownloadProgressBar(tqdm): + def update_to(self, b=1, bsize=1, tsize=None): + if tsize is not None: + self.total = tsize + self.update(b * bsize - self.n) + + +class SdkManager: + def __init__(self, android_sdk_dir: Path, dry_run: bool = False): + self._sdk_manager = android_sdk_dir / "tools" / "bin" / "sdkmanager" + + if not self._sdk_manager.exists(): + raise RuntimeError(f"Unable to find SdkManager in {str(self._sdk_manager)}") + + if not os.access(self._sdk_manager, os.X_OK): + current_permissions = stat.S_IMODE(os.lstat(self._sdk_manager).st_mode) + os.chmod(self._sdk_manager, current_permissions | stat.S_IEXEC) + + self._android_sdk_dir = android_sdk_dir + self._dry_run = dry_run + + def list_packages(self): + command = [self._sdk_manager, f"--sdk_root={self._android_sdk_dir}", "--list"] + return run_command(command=command, dry_run=self._dry_run, show_stdout=True, + capture_stdout=True) + + def install(self, *args, accept_license: bool = False, show_stdout=False): + command = [str(self._sdk_manager), f"--sdk_root={self._android_sdk_dir}", *args] + run_command(command=command, dry_run=self._dry_run, + accept_prompts=accept_license, show_stdout=show_stdout) + + +def extract_zip(file: Path, destination: Path): + """ + Unpacks the zip file into destination preserving all permissions + + TODO: Try to use zipfile module. Currently we cannot use zipfile module here because + extractAll() does not preserve permissions. + + In case `unzip` is not available, the user is requested to install it manually + """ + unzip = shutil.which("unzip") + if not unzip: + raise RuntimeError("Unable to find program unzip. Use `sudo apt-get install unzip`" + "to install it") + + command = [unzip, file, "-d", destination] + run_command(command=command, show_stdout=True) + + +def extract_dmg(file: Path, destination: Path): + output = run_command(['hdiutil', 'attach', '-nobrowse', '-readonly', file], + show_stdout=True, capture_stdout=True) + + # find the mounted volume + mounted_vol_name = re.search(r'/Volumes/(.*)', output).group(1) + if not mounted_vol_name: + raise RuntimeError(f"Unable to find mounted volume for file {file}") + + # copy files + shutil.copytree(f'/Volumes/{mounted_vol_name}/', destination, dirs_exist_ok=True) + + # Detach mounted volume + run_command(['hdiutil', 'detach', f'/Volumes/{mounted_vol_name}']) + + +def _download(url: str, destination: Path): + """ + Download url to destination + """ + headers, download_path = None, None + # https://github.com/tqdm/tqdm#hooks-and-callbacks + with DownloadProgressBar(unit='B', unit_scale=True, miniters=1, desc=url.split('/')[-1]) as t: + download_path, headers = request.urlretrieve(url=url, filename=destination, + reporthook=t.update_to) + assert Path(download_path).resolve() == destination + + +def download_android_ndk(ndk_path: Path): + """ + Downloads the given ndk_version into ndk_path + """ + ndk_path = ndk_path / "android-ndk" + ndk_extension = "dmg" if sys.platform == "darwin" else "zip" + ndk_zip_path = ndk_path / f"android-ndk-r{ANDROID_NDK_VERSION}-{sys.platform}.{ndk_extension}" + ndk_version_path = "" + if sys.platform == "linux": + ndk_version_path = ndk_path / f"android-ndk-r{ANDROID_NDK_VERSION}" + elif sys.platform == "darwin": + ndk_version_path = (ndk_path + / f"AndroidNDK{ANDROID_NDK_VERSION_NUMBER_SUFFIX}.app/Contents/NDK") + else: + raise RuntimeError(f"Unsupported platform {sys.platform}") + + if ndk_version_path.exists(): + print(f"NDK path found in {str(ndk_version_path)}") + else: + ndk_path.mkdir(parents=True, exist_ok=True) + url = (f"https://dl.google.com/android/repository" + f"/android-ndk-r{ANDROID_NDK_VERSION}-{sys.platform}.{ndk_extension}") + + print(f"Downloading Android Ndk version r{ANDROID_NDK_VERSION}") + _download(url=url, destination=ndk_zip_path) + + print("Unpacking Android Ndk") + if sys.platform == "darwin": + extract_dmg(file=(ndk_path + / f"android-ndk-r{ANDROID_NDK_VERSION}-{sys.platform}.{ndk_extension}" + ), + destination=ndk_path) + ndk_version_path = (ndk_version_path + / f"AndroidNDK{ANDROID_NDK_VERSION_NUMBER_SUFFIX}.app/Contents/NDK") + else: + extract_zip(file=(ndk_path + / f"android-ndk-r{ANDROID_NDK_VERSION}-{sys.platform}.{ndk_extension}" + ), + destination=ndk_path) + + return ndk_version_path + + +def download_android_commandlinetools(android_sdk_dir: Path): + """ + Downloads Android commandline tools into cltools_path. + """ + sdk_platform = sys.platform if sys.platform != "darwin" else "mac" + android_sdk_dir = android_sdk_dir / "android-sdk" + url = ("https://dl.google.com/android/repository/" + f"commandlinetools-{sdk_platform}-{DEFAULT_SDK_TAG}_latest.zip") + cltools_zip_path = (android_sdk_dir + / f"commandlinetools-{sdk_platform}-{DEFAULT_SDK_TAG}_latest.zip") + cltools_path = android_sdk_dir / "tools" + + if cltools_path.exists(): + print(f"Command-line tools found in {str(cltools_path)}") + else: + android_sdk_dir.mkdir(parents=True, exist_ok=True) + + print("Download Android Command Line Tools: " + f"commandlinetools-{sys.platform}-{DEFAULT_SDK_TAG}_latest.zip") + _download(url=url, destination=cltools_zip_path) + + print("Unpacking Android Command Line Tools") + extract_zip(file=cltools_zip_path, destination=android_sdk_dir) + + return android_sdk_dir + + +def android_list_build_tools_versions(sdk_manager: SdkManager): + """ + List all the build-tools versions available for download + """ + available_packages = sdk_manager.list_packages() + build_tools_versions = [] + lines = available_packages.split('\n') + + for line in lines: + if not line.strip().startswith('build-tools;'): + continue + package_name = line.strip().split(' ')[0] + if package_name.count(';') != 1: + raise RuntimeError(f"Unable to parse build-tools version: {package_name}") + ver = package_name.split(';')[1] + + build_tools_versions.append(version.Version(ver)) + + return build_tools_versions + + +def find_installed_buildtools_version(build_tools_dir: Path): + """ + It is possible that the user has multiple build-tools installed. The newer version is generally + used. This function find the newest among the installed build-tools + """ + versions = [version.Version(bt_dir.name) for bt_dir in build_tools_dir.iterdir() + if bt_dir.is_dir()] + return max(versions) + + +def find_latest_buildtools_version(sdk_manager: SdkManager): + """ + Uses sdk manager to find the latest build-tools version + """ + available_build_tools_v = android_list_build_tools_versions(sdk_manager=sdk_manager) + + if not available_build_tools_v: + raise RuntimeError('Unable to find any build tools available for download') + + # find the latest build tools version that is not a release candidate + # release candidates end has rc in the version number + available_build_tools_v = [v for v in available_build_tools_v if "rc" not in str(v)] + + return max(available_build_tools_v) + + +def install_android_packages(android_sdk_dir: Path, android_api: str, dry_run: bool = False, + accept_license: bool = False, skip_update: bool = False): + """ + Use the sdk manager to install build-tools, platform-tools and platform API + """ + tools_dir = android_sdk_dir / "tools" + if not tools_dir.exists(): + raise RuntimeError("Unable to find Android command-line tools in " + f"{str(tools_dir)}") + + # incase of --verbose flag + show_output = (logging.getLogger().getEffectiveLevel() == logging.INFO) + + sdk_manager = SdkManager(android_sdk_dir=android_sdk_dir, dry_run=dry_run) + + # install/upgrade platform-tools + if not (android_sdk_dir / "platform-tools").exists(): + print("Installing/Updating Android platform-tools") + sdk_manager.install("platform-tools", accept_license=accept_license, + show_stdout=show_output) + # The --update command is only relevant for platform tools + if not skip_update: + sdk_manager.install("--update", show_stdout=show_output) + + # install/upgrade build-tools + buildtools_dir = android_sdk_dir / "build-tools" + + if not buildtools_dir.exists(): + latest_build_tools_v = find_latest_buildtools_version(sdk_manager=sdk_manager) + print(f"Installing Android build-tools version {latest_build_tools_v}") + sdk_manager.install(f"build-tools;{latest_build_tools_v}", show_stdout=show_output) + else: + if not skip_update: + latest_build_tools_v = find_latest_buildtools_version(sdk_manager=sdk_manager) + installed_build_tools_v = find_installed_buildtools_version(buildtools_dir) + if latest_build_tools_v > installed_build_tools_v: + print(f"Updating Android build-tools version to {latest_build_tools_v}") + sdk_manager.install(f"build-tools;{latest_build_tools_v}", show_stdout=show_output) + installed_build_tools_v = latest_build_tools_v + + # install the platform API + platform_api_dir = android_sdk_dir / "platforms" / f"android-{android_api}" + if not platform_api_dir.exists(): + print(f"Installing Android platform API {android_api}") + sdk_manager.install(f"platforms;android-{android_api}", show_stdout=show_output) + + print("Android packages installation done") diff --git a/tools/cross_compile_android/main.py b/tools/cross_compile_android/main.py new file mode 100644 index 000000000..200f494cf --- /dev/null +++ b/tools/cross_compile_android/main.py @@ -0,0 +1,309 @@ +# 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 argparse +import stat +import warnings +import shutil +from dataclasses import dataclass + +from pathlib import Path +from git import Repo, RemoteProgress +from tqdm import tqdm +from jinja2 import Environment, FileSystemLoader + +from android_utilities import (run_command, download_android_commandlinetools, + download_android_ndk, install_android_packages) + +# Note: Does not work with PyEnv. Your Host Python should contain openssl. +# also update the version in ShibokenHelpers.cmake if Python version changes. +PYTHON_VERSION = "3.11" + +SKIP_UPDATE_HELP = ("skip the updation of SDK packages build-tools, platform-tools to" + " latest version") + +ACCEPT_LICENSE_HELP = (''' +Accepts license automatically for Android SDK installation. Otherwise, +accept the license manually through command line. +''') + +CLEAN_CACHE_HELP = (''' +Cleans cache stored in $HOME/.pyside6_deploy_cache. +Options: + +1. all - all the cache including Android Ndk, Android Sdk and Cross-compiled Python are deleted. +2. ndk - Only the Android Ndk is deleted. +3. sdk - Only the Android Sdk is deleted. +4. python - The cross compiled Python for all platforms, the cloned CPython, the cross compilation + scripts for all platforms are deleted. +5. toolchain - The CMake toolchain file required for cross-compiling Qt for Python, for all + platforms are deleted. + +If --clean-cache is used and no explicit value is suppied, then `all` is used as default. +''') + + +@dataclass +class PlatformData: + plat_name: str + api_level: str + android_abi: str + qt_plat_name: str + gcc_march: str + plat_bits: str + + +def occp_exists(): + ''' + check if '--only-cross-compile-python' exists in command line arguments + ''' + return "-occp" in sys.argv or "--only-cross-compile-python" in sys.argv + + +class CloneProgress(RemoteProgress): + def __init__(self): + super().__init__() + self.pbar = tqdm() + + def update(self, op_code, cur_count, max_count=None, message=""): + self.pbar.total = max_count + self.pbar.n = cur_count + self.pbar.refresh() + + +if __name__ == "__main__": + parser = argparse.ArgumentParser( + description="This tool cross builds CPython for Android and uses that Python to cross build" + "Android Qt for Python wheels", + formatter_class=argparse.RawTextHelpFormatter, + ) + + parser.add_argument("-p", "--plat-name", type=str, nargs="*", + choices=["aarch64", "armv7a", "i686", "x86_64"], + default=["aarch64", "armv7a", "i686", "x86_64"], dest="plat_names", + help="Android target platforms") + + parser.add_argument("-v", "--verbose", help="run in verbose mode", action="store_const", + dest="loglevel", const=logging.INFO) + parser.add_argument("--api-level", type=str, default="26", + help="Minimum Android API level to use") + parser.add_argument("--ndk-path", type=str, help="Path to Android NDK (Preferred r25c)") + # sdk path is needed to compile all the Qt Java Acitivity files into Qt6AndroidBindings.jar + parser.add_argument("--sdk-path", type=str, help="Path to Android SDK") + parser.add_argument("--qt-install-path", type=str, required=not occp_exists(), + help="Qt installation path eg: /home/Qt/6.5.0") + + parser.add_argument("-occp", "--only-cross-compile-python", action="store_true", + help="Only cross compiles Python for the specified Android platform") + + parser.add_argument("--dry-run", action="store_true", help="show the commands to be run") + + parser.add_argument("--skip-update", action="store_true", + help=SKIP_UPDATE_HELP) + + parser.add_argument("--auto-accept-license", action="store_true", + help=ACCEPT_LICENSE_HELP) + + parser.add_argument("--clean-cache", type=str, nargs="?", const="all", + choices=["all", "python", "ndk", "sdk", "toolchain"], + help=CLEAN_CACHE_HELP) + + args = parser.parse_args() + + logging.basicConfig(level=args.loglevel) + pyside_setup_dir = Path(__file__).parents[2].resolve() + qt_install_path = args.qt_install_path + ndk_path = args.ndk_path + sdk_path = args.sdk_path + only_py_cross_compile = args.only_cross_compile_python + android_abi = None + gcc_march = None + plat_bits = None + dry_run = args.dry_run + plat_names = args.plat_names + api_level = args.api_level + skip_update = args.skip_update + auto_accept_license = args.auto_accept_license + clean_cache = args.clean_cache + + # auto download Android NDK and SDK + pyside6_deploy_cache = Path.home() / ".pyside6_android_deploy" + logging.info(f"Cache created at {str(pyside6_deploy_cache.resolve())}") + pyside6_deploy_cache.mkdir(exist_ok=True) + + if pyside6_deploy_cache.exists() and clean_cache: + if clean_cache == "all": + shutil.rmtree(pyside6_deploy_cache) + elif clean_cache == "ndk": + cached_ndk_dir = pyside6_deploy_cache / "android-ndk" + if cached_ndk_dir.exists(): + shutil.rmtree(cached_ndk_dir) + elif clean_cache == "sdk": + cached_sdk_dir = pyside6_deploy_cache / "android-sdk" + if cached_sdk_dir.exists(): + shutil.rmtree(cached_sdk_dir) + elif clean_cache == "python": + cached_cpython_dir = pyside6_deploy_cache / "cpython" + if cached_cpython_dir.exists(): + shutil.rmtree(pyside6_deploy_cache / "cpython") + for cc_python_path in pyside6_deploy_cache.glob("Python-*"): + if cc_python_path.is_dir(): + shutil.rmtree(cc_python_path) + elif clean_cache == "toolchain": + for toolchain_path in pyside6_deploy_cache.glob("toolchain_*"): + if toolchain_path.is_file(): + toolchain_path.unlink() + + if not ndk_path: + # Download android ndk + ndk_path = download_android_ndk(pyside6_deploy_cache) + + if not sdk_path: + # download and unzip command-line tools + sdk_path = download_android_commandlinetools(pyside6_deploy_cache) + # install and update required android packages + install_android_packages(android_sdk_dir=sdk_path, android_api=api_level, dry_run=dry_run, + accept_license=auto_accept_license, skip_update=skip_update) + + templates_path = Path(__file__).parent / "templates" + + for plat_name in plat_names: + # for armv7a the API level dependent binaries like clang are named + # armv7a-linux-androideabi27-clang, as opposed to other platforms which + # are named like x86_64-linux-android27-clang + platform_data = None + if plat_name == "armv7a": + platform_data = PlatformData("armv7a", api_level, "armeabi-v7a", "armv7", + "armv7", "32") + elif plat_name == "aarch64": + platform_data = PlatformData("aarch64", api_level, "arm64-v8a", "arm64_v8a", "armv8-a", + "64") + elif plat_name == "i686": + platform_data = PlatformData("i686", api_level, "x86", "x86", "i686", "32") + else: # plat_name is x86_64 + platform_data = PlatformData("x86_64", api_level, "x86_64", "x86_64", "x86-64", "64") + + # python path is valid, if Python for android installation exists in python_path + python_path = (pyside6_deploy_cache + / f"Python-{platform_data.plat_name}-linux-android" / "_install") + valid_python_path = python_path.exists() + if Path(python_path).exists(): + expected_dirs = ["lib", "include"] + for expected_dir in expected_dirs: + if not (Path(python_path) / expected_dir).is_dir(): + valid_python_path = False + warnings.warn( + f"{str(python_path.resolve())} is corrupted. New Python for {plat_name} " + f"android will be cross-compiled into {str(pyside6_deploy_cache.resolve())}" + ) + break + + environment = Environment(loader=FileSystemLoader(templates_path)) + if not valid_python_path: + # clone cpython and checkout 3.10 + cpython_dir = pyside6_deploy_cache / "cpython" + python_ccompile_script = cpython_dir / f"cross_compile_{plat_name}.sh" + + if not cpython_dir.exists(): + logging.info(f"cloning cpython {PYTHON_VERSION}") + Repo.clone_from( + "https://github.com/python/cpython.git", + cpython_dir, + progress=CloneProgress(), + branch=PYTHON_VERSION, + ) + + if not python_ccompile_script.exists(): + host_system_config_name = run_command("./config.guess", cwd=cpython_dir, + dry_run=dry_run, show_stdout=True, + capture_stdout=True).strip() + + # use jinja2 to create cross_compile.sh script + template = environment.get_template("cross_compile.tmpl.sh") + content = template.render( + plat_name=platform_data.plat_name, + ndk_path=ndk_path, + api_level=platform_data.api_level, + android_py_install_path_prefix=pyside6_deploy_cache, + host_python_path=sys.executable, + python_version=PYTHON_VERSION, + host_system_name=host_system_config_name, + host_platform_name=sys.platform + ) + + logging.info(f"Writing Python cross compile script into {python_ccompile_script}") + with open(python_ccompile_script, mode="w", encoding="utf-8") as ccompile_script: + ccompile_script.write(content) + + # give run permission to cross compile script + python_ccompile_script.chmod(python_ccompile_script.stat().st_mode | stat.S_IEXEC) + + # clean built files + logging.info("Cleaning CPython built files") + run_command(["make", "distclean"], cwd=cpython_dir, dry_run=dry_run, ignore_fail=True) + + # run the cross compile script + logging.info(f"Running Python cross-compile for platform {platform_data.plat_name}") + run_command([f"./{python_ccompile_script.name}"], cwd=cpython_dir, dry_run=dry_run, + show_stdout=True) + + logging.info( + f"Cross compile Python for Android platform {platform_data.plat_name}. " + f"Final installation in {python_path}" + ) + + if only_py_cross_compile: + continue + + if only_py_cross_compile: + requested_platforms = ",".join(plat_names) + print(f"Python for Android platforms: {requested_platforms} cross compiled " + f"to {str(pyside6_deploy_cache)}") + sys.exit(0) + + qfp_toolchain = pyside6_deploy_cache / f"toolchain_{platform_data.plat_name}.cmake" + + if not qfp_toolchain.exists(): + template = environment.get_template("toolchain_default.tmpl.cmake") + content = template.render( + ndk_path=ndk_path, + sdk_path=sdk_path, + api_level=platform_data.api_level, + qt_install_path=qt_install_path, + plat_name=platform_data.plat_name, + android_abi=platform_data.android_abi, + qt_plat_name=platform_data.qt_plat_name, + gcc_march=platform_data.gcc_march, + plat_bits=platform_data.plat_bits, + python_version=PYTHON_VERSION, + target_python_path=python_path + ) + + logging.info(f"Writing Qt for Python toolchain file into {qfp_toolchain}") + with open(qfp_toolchain, mode="w", encoding="utf-8") as ccompile_script: + ccompile_script.write(content) + + # give run permission to cross compile script + qfp_toolchain.chmod(qfp_toolchain.stat().st_mode | stat.S_IEXEC) + + if sys.platform == "linux": + host_qt_install_suffix = "gcc_64" + elif sys.platform == "darwin": + host_qt_install_suffix = "macos" + else: + raise RuntimeError("Qt for Python cross compilation not supported on this platform") + + # run the cross compile script + logging.info(f"Running Qt for Python cross-compile for platform {platform_data.plat_name}") + qfp_ccompile_cmd = [sys.executable, "setup.py", "bdist_wheel", "--parallel=9", + "--standalone", + f"--cmake-toolchain-file={str(qfp_toolchain.resolve())}", + f"--qt-host-path={qt_install_path}/{host_qt_install_suffix}", + f"--plat-name=android_{platform_data.plat_name}", + f"--python-target-path={python_path}", + (f"--qt-target-path={qt_install_path}/" + f"android_{platform_data.qt_plat_name}"), + "--no-qt-tools"] + run_command(qfp_ccompile_cmd, cwd=pyside_setup_dir, dry_run=dry_run, show_stdout=True) diff --git a/tools/cross_compile_android/requirements.txt b/tools/cross_compile_android/requirements.txt new file mode 100644 index 000000000..62e8ee3b0 --- /dev/null +++ b/tools/cross_compile_android/requirements.txt @@ -0,0 +1,3 @@ +gitpython +Jinja2 +tqdm diff --git a/tools/cross_compile_android/templates/cross_compile.tmpl.sh b/tools/cross_compile_android/templates/cross_compile.tmpl.sh new file mode 100644 index 000000000..784e822ca --- /dev/null +++ b/tools/cross_compile_android/templates/cross_compile.tmpl.sh @@ -0,0 +1,29 @@ +#!/bin/bash +# 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 +set -x -e +export HOST_ARCH={{ plat_name }}-linux-android +export TOOLCHAIN={{ ndk_path }}/toolchains/llvm/prebuilt/{{ host_platform_name }}-x86_64/bin +export TOOL_PREFIX=$TOOLCHAIN/$HOST_ARCH +export PLATFORM_API={{ api_level }} +{% if plat_name == "armv7a" -%} +export CXX=${TOOL_PREFIX}eabi${PLATFORM_API}-clang++ +export CPP="${TOOL_PREFIX}eabi${PLATFORM_API}-clang++ -E" +export CC=${TOOL_PREFIX}eabi${PLATFORM_API}-clang +{% else %} +export CXX=${TOOL_PREFIX}${PLATFORM_API}-clang++ +export CPP="${TOOL_PREFIX}${PLATFORM_API}-clang++ -E" +export CC=${TOOL_PREFIX}${PLATFORM_API}-clang +{% endif %} +export AR=$TOOLCHAIN/llvm-ar +export RANLIB=$TOOLCHAIN/llvm-ranlib +export LD=$TOOLCHAIN/ld +export READELF=$TOOLCHAIN/llvm-readelf +export CFLAGS='-fPIC -DANDROID' +./configure --host=$HOST_ARCH --target=$HOST_ARCH --build={{ host_system_name }} \ +--with-build-python={{ host_python_path }} --enable-shared \ +--enable-ipv6 ac_cv_file__dev_ptmx=yes ac_cv_file__dev_ptc=no --without-ensurepip \ +ac_cv_little_endian_double=yes +make BLDSHARED="$CC -shared" CROSS-COMPILE=$TOOL_PREFIX- CROSS_COMPILE_TARGET=yes \ +INSTSONAME=libpython{{ python_version }}.so +make install prefix={{ android_py_install_path_prefix }}/Python-$HOST_ARCH/_install diff --git a/tools/cross_compile_android/templates/toolchain_default.tmpl.cmake b/tools/cross_compile_android/templates/toolchain_default.tmpl.cmake new file mode 100644 index 000000000..3c9752f43 --- /dev/null +++ b/tools/cross_compile_android/templates/toolchain_default.tmpl.cmake @@ -0,0 +1,73 @@ +# 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 + +# toolchain file to cross compile Qt for Python wheels for Android +cmake_minimum_required(VERSION 3.23) +include_guard(GLOBAL) +set(CMAKE_SYSTEM_NAME Android) +{% if plat_name == "armv7a" -%} +set(CMAKE_SYSTEM_PROCESSOR armv7-a) +{% else %} +set(CMAKE_SYSTEM_PROCESSOR {{ plat_name }}) +{% endif %} +set(CMAKE_ANDROID_API {{ api_level }}) +set(CMAKE_ANDROID_NDK {{ ndk_path }}) +set(CMAKE_ANDROID_ARCH_ABI {{ android_abi }}) +set(CMAKE_ANDROID_NDK_TOOLCHAIN_VERSION clang) +set(CMAKE_ANDROID_STL_TYPE c++_shared) +if(NOT DEFINED ANDROID_PLATFORM AND NOT DEFINED ANDROID_NATIVE_API_LEVEL) + set(ANDROID_PLATFORM "android-{{ api_level }}" CACHE STRING "") +endif() +set(ANDROID_SDK_ROOT {{ sdk_path }}) +{% if plat_name == "armv7a" -%} +set(_TARGET_NAME_ENDING "eabi{{ api_level }}") +{% else %} +set(_TARGET_NAME_ENDING "{{ api_level }}") +{% endif %} +set(QT_COMPILER_FLAGS "--target={{ plat_name }}-linux-android${_TARGET_NAME_ENDING} \ + -fomit-frame-pointer \ + -march={{ gcc_march }} \ + -msse4.2 \ + -mpopcnt \ + -m{{ plat_bits }} \ + -fPIC \ + -I{{ target_python_path }}/include/python{{ python_version }} \ + -Wno-unused-command-line-argument") +set(QT_COMPILER_FLAGS_RELEASE "-O2 -pipe") + +# FIXME +# https://gitlab.kitware.com/cmake/cmake/-/issues/23670 +# The CMake Android toolchain does not allow RPATHS. Hence CMAKE_INSTALL_RPATH does not work. +# Currently the linker flags are set directly as -Wl,-rpath='$ORIGIN' -Wl,-rpath='$ORIGIN/Qt/lib' +# set(CMAKE_BUILD_WITH_INSTALL_RPATH TRUE) +# set(CMAKE_INSTALL_RPATH "$ORIGIN") + +set(QT_LINKER_FLAGS "-Wl,-O1 -Wl,--hash-style=gnu -Wl,-rpath='$ORIGIN' -Wl,-rpath='$ORIGIN/Qt/lib' \ + -Wl,--as-needed -L{{ qt_install_path }}/android_{{ qt_plat_name }}/lib \ + -L{{ qt_install_path }}/android_{{ qt_plat_name }}/plugins/platforms \ + -L{{ target_python_path }}/lib \ + -lpython{{ python_version }}") +set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER) +set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY) +set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY) +set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY) + +add_compile_definitions(ANDROID) + +include(CMakeInitializeConfigs) +function(cmake_initialize_per_config_variable _PREFIX _DOCSTRING) + if (_PREFIX MATCHES "CMAKE_(C|CXX|ASM)_FLAGS") + set(CMAKE_${CMAKE_MATCH_1}_FLAGS_INIT "${QT_COMPILER_FLAGS}") + foreach (config DEBUG RELEASE MINSIZEREL RELWITHDEBINFO) + if (DEFINED QT_COMPILER_FLAGS_${config}) + set(CMAKE_${CMAKE_MATCH_1}_FLAGS_${config}_INIT "${QT_COMPILER_FLAGS_${config}}") + endif() + endforeach() + endif() + if (_PREFIX MATCHES "CMAKE_(SHARED|MODULE|EXE)_LINKER_FLAGS") + foreach (config SHARED MODULE EXE) + set(CMAKE_${config}_LINKER_FLAGS_INIT "${QT_LINKER_FLAGS}") + endforeach() + endif() + _cmake_initialize_per_config_variable(${ARGV}) +endfunction() diff --git a/tools/doc_modules.py b/tools/doc_modules.py index 7738e21bd..d46f4db02 100644 --- a/tools/doc_modules.py +++ b/tools/doc_modules.py @@ -54,7 +54,7 @@ def required_typesystems(module): parser.setContentHandler(handler) parser.parse(typesystem_file) except Exception as e: - print(f"Error parsing {typesystem_file}: {e}", file=sys.stderr) + print(f"Warning: XML error parsing {typesystem_file}: {e}", file=sys.stderr) return handler.required_modules @@ -189,6 +189,9 @@ if __name__ == "__main__": module_dependency_dict = {} for m in SOURCE_DIR.glob("Qt*"): module = m.name + # QtGraphs duplicates symbols from QtDataVisualization causing shiboken errors + if module == "QtDataVisualization": + continue qt_include_path = qt_include_dir / module if qt_include_path.is_dir(): module_dependency_dict[module] = required_typesystems(module) diff --git a/tools/example_gallery/main.py b/tools/example_gallery/main.py index 5e9029f27..b5aa632c0 100644 --- a/tools/example_gallery/main.py +++ b/tools/example_gallery/main.py @@ -14,13 +14,43 @@ since there is no special requirements. import json import math +import os import shutil +import zipfile import sys from argparse import ArgumentParser, RawTextHelpFormatter +from dataclasses import dataclass +from enum import IntEnum, Enum from pathlib import Path from textwrap import dedent + +class Format(Enum): + RST = 0 + MD = 1 + + +class ModuleType(IntEnum): + ESSENTIALS = 0 + ADDONS = 1 + M2M = 2 + + +SUFFIXES = {Format.RST: "rst", Format.MD: "md"} + + opt_quiet = False + + +LITERAL_INCLUDE = ".. literalinclude::" + + +IMAGE_SUFFIXES = (".png", ".jpg", ".jpeg", ".gif", ".svg", ".svgz", ".webp") + + +IGNORED_SUFFIXES = IMAGE_SUFFIXES + (".pdf", ".pyc", ".obj", ".mesh") + + suffixes = { ".h": "cpp", ".cpp": "cpp", @@ -31,9 +61,25 @@ suffixes = { ".qrc": "xml", ".ui": "xml", ".xbel": "xml", + ".xml": "xml", } +BASE_CONTENT = """\ +Examples +======== + + A collection of examples are provided with |project| to help new users + to understand different use cases of the module. + + You can find all these examples inside the + `pyside-setup <https://code.qt.io/cgit/pyside/pyside-setup.git/>`_ repository + on the `examples <https://code.qt.io/cgit/pyside/pyside-setup.git/tree/examples>`_ + directory. + +""" + + def ind(x): return " " * 4 * x @@ -41,10 +87,8 @@ def ind(x): def get_lexer(path): if path.name == "CMakeLists.txt": return "cmake" - suffix = path.suffix - if suffix in suffixes: - return suffixes[suffix] - return "text" + lexer = suffixes.get(path.suffix) + return lexer if lexer else "text" def add_indent(s, level): @@ -57,6 +101,110 @@ def add_indent(s, level): return new_s +def check_img_ext(i): + """Check whether path is an image.""" + return i.suffix in IMAGE_SUFFIXES + + +@dataclass +class ModuleDescription: + """Specifies a sort key and type for a Qt module.""" + sort_key: int = 0 + module_type: ModuleType = ModuleType.ESSENTIALS + description: str = '' + + +MODULE_DESCRIPTIONS = { + "async": ModuleDescription(16, ModuleType.ESSENTIALS, ''), + "corelib": ModuleDescription(15, ModuleType.ESSENTIALS, ''), + "dbus": ModuleDescription(22, ModuleType.ESSENTIALS, ''), + "designer": ModuleDescription(11, ModuleType.ESSENTIALS, ''), + "gui": ModuleDescription(25, ModuleType.ESSENTIALS, ''), + "network": ModuleDescription(20, ModuleType.ESSENTIALS, ''), + "opengl": ModuleDescription(26, ModuleType.ESSENTIALS, ''), + "qml": ModuleDescription(0, ModuleType.ESSENTIALS, ''), + "quick": ModuleDescription(1, ModuleType.ESSENTIALS, ''), + "quickcontrols": ModuleDescription(2, ModuleType.ESSENTIALS, ''), + "samplebinding": ModuleDescription(30, ModuleType.ESSENTIALS, ''), + "scriptableapplication": ModuleDescription(30, ModuleType.ESSENTIALS, ''), + "sql": ModuleDescription(21, ModuleType.ESSENTIALS, ''), + "uitools": ModuleDescription(12, ModuleType.ESSENTIALS, ''), + "widgetbinding": ModuleDescription(30, ModuleType.ESSENTIALS, ''), + "widgets": ModuleDescription(10, ModuleType.ESSENTIALS, ''), + "xml": ModuleDescription(24, ModuleType.ESSENTIALS, ''), + "Qt Demos": ModuleDescription(0, ModuleType.ADDONS, ''), # from Qt repos + "3d": ModuleDescription(30, ModuleType.ADDONS, ''), + "axcontainer": ModuleDescription(20, ModuleType.ADDONS, ''), + "bluetooth": ModuleDescription(20, ModuleType.ADDONS, ''), + "charts": ModuleDescription(12, ModuleType.ADDONS, ''), + "datavisualization": ModuleDescription(11, ModuleType.ADDONS, ''), + "demos": ModuleDescription(0, ModuleType.ADDONS, ''), + "external": ModuleDescription(20, ModuleType.ADDONS, ''), + "graphs": ModuleDescription(10, ModuleType.ADDONS, ''), + "httpserver": ModuleDescription(0, ModuleType.ADDONS, ''), + "location": ModuleDescription(20, ModuleType.ADDONS, ''), + "multimedia": ModuleDescription(12, ModuleType.ADDONS, ''), + "networkauth": ModuleDescription(20, ModuleType.ADDONS, ''), + "pdf": ModuleDescription(20, ModuleType.ADDONS, ''), + "pdfwidgets": ModuleDescription(20, ModuleType.ADDONS, ''), + "quick3d": ModuleDescription(20, ModuleType.ADDONS, ''), + "remoteobjects": ModuleDescription(20, ModuleType.ADDONS, ''), + "serialbus": ModuleDescription(30, ModuleType.ADDONS, ''), + "serialport": ModuleDescription(30, ModuleType.ADDONS, ''), + "spatialaudio": ModuleDescription(20, ModuleType.ADDONS, ''), + "speech": ModuleDescription(20, ModuleType.ADDONS, ''), + "statemachine": ModuleDescription(30, ModuleType.ADDONS, ''), + "webchannel": ModuleDescription(30, ModuleType.ADDONS, ''), + "webenginequick": ModuleDescription(15, ModuleType.ADDONS, ''), + "webenginewidgets": ModuleDescription(16, ModuleType.ADDONS, ''), + "coap": ModuleDescription(0, ModuleType.M2M, ''), + "mqtt": ModuleDescription(0, ModuleType.M2M, ''), + "opcua": ModuleDescription(0, ModuleType.M2M, '') +} + + +def module_sort_key(name): + """Return key for sorting modules.""" + description = MODULE_DESCRIPTIONS.get(name) + module_type = int(description.module_type) if description else 5 + sort_key = description.sort_key if description else 100 + return f"{module_type}:{sort_key:04}:{name}" + + +def module_title(name): + """Return title for a module.""" + result = name.title() + description = MODULE_DESCRIPTIONS.get(name) + if description: + if description.description: + result += " - " + description.description + if description.module_type == ModuleType.M2M: + result += " (M2M)" + elif description.module_type == ModuleType.ADDONS: + result += " (Add-ons)" + else: + result += " (Essentials)" + return result + + +@dataclass +class ExampleData: + """Example data for formatting the gallery.""" + + def __init__(self): + self.headline = "" + + example: str + module: str + extra: str + doc_file: str + file_format: Format + abs_path: str + has_doc: bool + img_doc: Path + headline: str + + def get_module_gallery(examples): """ This function takes a list of dictionaries, that contain examples @@ -64,45 +212,40 @@ def get_module_gallery(examples): """ gallery = ( - ".. panels::\n" - f"{ind(1)}:container: container-lg pb-3\n" - f"{ind(1)}:column: col-lg-3 col-md-6 col-sm-6 col-xs-12 p-2\n\n" + ".. grid:: 1 4 4 4\n" + f"{ind(1)}:gutter: 2\n\n" ) # Iteration per rows for i in range(math.ceil(len(examples))): e = examples[i] - url = e["rst"].replace(".rst", ".html") - name = e["example"] - underline = f'{e["module"]}' + suffix = SUFFIXES[e.file_format] + url = e.doc_file.replace(f".{suffix}", ".html") + name = e.example + underline = e.module - if e["extra"]: - underline += f'/{e["extra"]}' + if e.extra: + underline += f"/{e.extra}" if i > 0: - gallery += f"{ind(1)}---\n" - elif e["img_doc"]: - gallery += f"{ind(1)}---\n" - - if e["img_doc"]: - img_name = e['img_doc'].name - else: - img_name = "../example_no_image.png" - - gallery += f"{ind(1)}:img-top: {img_name}\n" - gallery += f"{ind(1)}:img-top-cls: + d-flex align-self-center\n\n" + gallery += "\n" + img_name = e.img_doc.name if e.img_doc else "../example_no_image.png" # Fix long names if name.startswith("chapter"): name = name.replace("chapter", "c") + elif name.startswith("advanced"): + name = name.replace("advanced", "a") - gallery += f"{ind(1)}`{name} <{url}>`_\n" - gallery += f"{ind(1)}+++\n" - gallery += f"{ind(1)}{underline}\n" - gallery += f"\n{ind(1)}.. link-button:: {url}\n" - gallery += f"{ind(2)}:type: url\n" - gallery += f"{ind(2)}:text: Go to Example\n" - gallery += f"{ind(2)}:classes: btn-qt btn-block stretched-link\n" + desc = e.headline + if not desc: + desc = f"found in the ``{underline}`` directory." + + gallery += f"{ind(1)}.. grid-item-card:: {name}\n" + gallery += f"{ind(2)}:class-item: cover-img\n" + gallery += f"{ind(2)}:link: {url}\n" + gallery += f"{ind(2)}:img-top: {img_name}\n\n" + gallery += f"{ind(2)}{desc}\n" return f"{gallery}\n" @@ -116,18 +259,69 @@ def remove_licenses(s): return "\n".join(new_s) -def get_code_tabs(files, project_dir): +def make_zip_archive(zip_name, src, skip_dirs=None): + src_path = Path(src).expanduser().resolve(strict=True) + if skip_dirs is None: + skip_dirs = [] + if not isinstance(skip_dirs, list): + print("Error: A list needs to be passed for 'skip_dirs'") + return + with zipfile.ZipFile(src_path.parents[0] / Path(zip_name), 'w', zipfile.ZIP_DEFLATED) as zf: + for file in src_path.rglob('*'): + skip = False + _parts = file.relative_to(src_path).parts + for sd in skip_dirs: + if sd in _parts: + skip = True + break + if not skip: + zf.write(file, file.relative_to(src_path.parent)) + + +def doc_file(project_dir, project_file_entry): + """Return the (optional) .rstinc file describing a source file.""" + rst_file = project_dir + if rst_file.name != "doc": # Special case: Dummy .pyproject file in doc dir + rst_file /= "doc" + rst_file /= Path(project_file_entry).name + ".rstinc" + return rst_file if rst_file.is_file() else None + + +def get_code_tabs(files, project_dir, file_format): content = "\n" + # Prepare ZIP file, and copy to final destination + zip_name = f"{project_dir.name}.zip" + make_zip_archive(zip_name, project_dir, skip_dirs=["doc"]) + zip_src = f"{project_dir}.zip" + zip_dst = EXAMPLES_DOC / zip_name + shutil.move(zip_src, zip_dst) + + if file_format == Format.RST: + content += f":download:`Download this example <{zip_name}>`\n\n" + else: + content += f"{{download}}`Download this example <{zip_name}>`\n\n" + content += "```{eval-rst}\n" + for i, project_file in enumerate(files): + if i == 0: + content += ".. tab-set::\n\n" + pfile = Path(project_file) - if pfile.suffix in (".jpg", ".pdf", ".png", ".pyc", ".svg", ".svgz"): + if pfile.suffix in IGNORED_SUFFIXES: continue - content += f".. tabbed:: {project_file}\n\n" + content += f"{ind(1)}.. tab-item:: {project_file}\n\n" + + doc_rstinc_file = doc_file(project_dir, project_file) + if doc_rstinc_file: + indent = ind(2) + for line in doc_rstinc_file.read_text("utf-8").split("\n"): + content += indent + line + "\n" + content += "\n" lexer = get_lexer(pfile) - content += add_indent(f".. code-block:: {lexer}", 1) + content += add_indent(f"{ind(1)}.. code-block:: {lexer}", 1) content += "\n" _path = project_dir / project_file @@ -144,8 +338,12 @@ def get_code_tabs(files, project_dir): file=sys.stderr) raise - content += add_indent(_file_content, 2) + content += add_indent(_file_content, 3) content += "\n\n" + + if file_format == Format.MD: + content += "```" + return content @@ -163,6 +361,253 @@ def get_header_title(example_dir): ) +def rel_path(from_path, to_path): + """Determine relative paths for paths that are not subpaths (where + relative_to() fails) via a common root.""" + common = Path(*os.path.commonprefix([from_path.parts, to_path.parts])) + up_dirs = len(from_path.parts) - len(common.parts) + prefix = up_dirs * "../" + rel_to_common = os.fspath(to_path.relative_to(common)) + return f"{prefix}{rel_to_common}" + + +def read_rst_file(project_dir, project_files, doc_rst): + """Read the example .rst file and expand literal includes to project files + by relative paths to the example directory. Note: sphinx does not + handle absolute paths as expected, they need to be relative.""" + content = "" + with open(doc_rst, encoding="utf-8") as doc_f: + content = doc_f.read() + if LITERAL_INCLUDE not in content: + return content + + result = [] + path_to_example = rel_path(EXAMPLES_DOC, project_dir) + for line in content.split("\n"): + if line.startswith(LITERAL_INCLUDE): + file = line[len(LITERAL_INCLUDE) + 1:].strip() + if file in project_files: + line = f"{LITERAL_INCLUDE} {path_to_example}/{file}" + result.append(line) + return "\n".join(result) + + +def get_headline(text, file_format): + """Find the headline in the .rst file.""" + if file_format == Format.RST: + underline = text.find("\n====") + if underline != -1: + start = text.rfind("\n", 0, underline - 1) + return text[start + 1:underline] + elif file_format == Format.MD: + headline = text.find("# ") + if headline != -1: + new_line = text.find("\n", headline + 1) + if new_line != -1: + return text[headline + 2:new_line].strip() + return "" + + +def get_doc_source_file(original_doc_dir, example_name): + """Find the doc source file, return (Path, Format).""" + if original_doc_dir.is_dir(): + for file_format in (Format.RST, Format.MD): + suffix = SUFFIXES[file_format] + result = original_doc_dir / f"{example_name}.{suffix}" + if result.is_file(): + return result, file_format + return None, Format.RST + + +def get_screenshot(image_dir, example_name): + """Find screen shot: We look for an image with the same + example_name first, if not, we select the first.""" + if not image_dir.is_dir(): + return None + images = [i for i in image_dir.glob("*") if i.is_file() and check_img_ext(i)] + example_images = [i for i in images if i.name.startswith(example_name)] + if example_images: + return example_images[0] + if images: + return images[0] + return None + + +def write_resources(src_list, dst): + """Write a list of example resource paths to the dst path.""" + for src in src_list: + resource_written = shutil.copy(src, dst / src.name) + if not opt_quiet: + print("Written resource:", resource_written) + + +@dataclass +class ExampleParameters: + """Parameters obtained from scanning the examples directory.""" + + def __init__(self): + self.file_format = Format.RST + self.src_doc_dir = self.src_doc_file_path = self.src_screenshot = None + self.extra_names = "" + + example_dir: Path + module_name: str + example_name: str + extra_names: str + file_format: Format + target_doc_file: str + src_doc_dir: Path + src_doc_file_path: Path + src_screenshot: Path + + +def detect_pyside_example(example_root, pyproject_file): + """Detemine parameters of a PySide example.""" + p = ExampleParameters() + + p.example_dir = pyproject_file.parent + if p.example_dir.name == "doc": # Dummy pyproject in doc dir (scriptableapplication) + p.example_dir = p.example_dir.parent + + parts = p.example_dir.parts[len(example_root.parts):] + p.module_name = parts[0] + p.example_name = parts[-1] + # handling subdirectories besides the module level and the example + p.extra_names = "" if len(parts) == 2 else "_".join(parts[1:-1]) + + # Check for a 'doc' directory inside the example + src_doc_dir = p.example_dir / "doc" + + if src_doc_dir.is_dir(): + src_doc_file_path, fmt = get_doc_source_file(src_doc_dir, p.example_name) + if src_doc_file_path: + p.src_doc_file_path = src_doc_file_path + p.file_format = fmt + p.src_doc_dir = src_doc_dir + p.src_screenshot = get_screenshot(src_doc_dir, p.example_name) + + target_suffix = SUFFIXES[p.file_format] + doc_file = f"example_{p.module_name}_{p.extra_names}_{p.example_name}.{target_suffix}" + p.target_doc_file = doc_file.replace("__", "_") + return p + + +def detect_qt_example(example_root, pyproject_file): + """Detemine parameters of an example from a Qt repository.""" + p = ExampleParameters() + + p.example_dir = pyproject_file.parent + p.module_name = "Qt Demos" + p.example_name = p.example_dir.name + # Check for a 'doc' directory inside the example (qdoc) + doc_root = p.example_dir / "doc" + if doc_root.is_dir(): + src_doc_file_path, fmt = get_doc_source_file(doc_root / "src", p.example_name) + if src_doc_file_path: + p.src_doc_file_path = src_doc_file_path + p.file_format = fmt + p.src_doc_dir = doc_root + p.src_screenshot = get_screenshot(doc_root / "images", p.example_name) + + target_suffix = SUFFIXES[p.file_format] + p.target_doc_file = f"example_qtdemos_{p.example_name}.{target_suffix}" + return p + + +def write_example(example_root, pyproject_file, pyside_example=True): + """Read the project file and documentation, create the .rst file and + copy the data. Return a tuple of module name and a dict of example data.""" + p = (detect_pyside_example(example_root, pyproject_file) if pyside_example + else detect_qt_example(example_root, pyproject_file)) + + result = ExampleData() + result.example = p.example_name + result.module = p.module_name + result.extra = p.extra_names + result.doc_file = p.target_doc_file + result.file_format = p.file_format + result.abs_path = str(p.example_dir) + result.has_doc = bool(p.src_doc_file_path) + result.img_doc = p.src_screenshot + + files = [] + try: + with pyproject_file.open("r", encoding="utf-8") as pyf: + pyproject = json.load(pyf) + # iterate through the list of files in .pyproject and + # check if they exist, before appending to the list. + for f in pyproject["files"]: + if not Path(f).exists: + print(f"example_gallery: {f} listed in {pyproject_file} does not exist") + raise FileNotFoundError + else: + files.append(f) + except (json.JSONDecodeError, KeyError, FileNotFoundError) as e: + print(f"example_gallery: error reading {pyproject_file}: {e}") + raise + + headline = "" + if files: + doc_file = EXAMPLES_DOC / p.target_doc_file + with open(doc_file, "w", encoding="utf-8") as out_f: + if p.src_doc_file_path: + content_f = read_rst_file(p.example_dir, files, p.src_doc_file_path) + headline = get_headline(content_f, p.file_format) + if not headline: + print(f"example_gallery: No headline found in {doc_file}", + file=sys.stderr) + + # Copy other files in the 'doc' directory, but + # excluding the main '.rst' file and all the + # directories. + resources = [] + if pyside_example: + for _f in p.src_doc_dir.glob("*"): + if _f != p.src_doc_file_path and not _f.is_dir(): + resources.append(_f) + else: # Qt example: only use image. + if p.src_screenshot: + resources.append(p.src_screenshot) + write_resources(resources, EXAMPLES_DOC) + else: + content_f = get_header_title(p.example_dir) + content_f += get_code_tabs(files, pyproject_file.parent, p.file_format) + out_f.write(content_f) + + if not opt_quiet: + print(f"Written: {doc_file}") + else: + if not opt_quiet: + print("Empty '.pyproject' file, skipping") + + result.headline = headline + + return (p.module_name, result) + + +def example_sort_key(example: ExampleData): + name = example.example + return "AAA" + name if "gallery" in name else name + + +def sort_examples(example): + result = {} + for module in example.keys(): + result[module] = sorted(example.get(module), key=example_sort_key) + return result + + +def scan_examples_dir(examples_dir, pyside_example=True): + """Scan a directory of examples.""" + for pyproject_file in examples_dir.glob("**/*.pyproject"): + if pyproject_file.name != "examples.pyproject": + module_name, data = write_example(examples_dir, pyproject_file, + pyside_example) + if module_name not in examples: + examples[module_name] = [] + examples[module_name].append(data) + + if __name__ == "__main__": # Only examples with a '.pyproject' file will be listed. DIR = Path(__file__).parent @@ -173,13 +618,14 @@ if __name__ == "__main__": gallery = "" parser = ArgumentParser(description=__doc__, formatter_class=RawTextHelpFormatter) - TARGET_HELP = f"Directory into which to generate RST files (default: {str(EXAMPLES_DOC)})" + TARGET_HELP = f"Directory into which to generate Doc files (default: {str(EXAMPLES_DOC)})" parser.add_argument("--target", "-t", action="store", dest="target_dir", help=TARGET_HELP) + parser.add_argument("--qt-src-dir", "-s", action="store", help="Qt source directory") parser.add_argument("--quiet", "-q", action="store_true", help="Quiet") options = parser.parse_args() opt_quiet = options.quiet if options.target_dir: - EXAMPLES_DOC = Path(options.target_dir) + EXAMPLES_DOC = Path(options.target_dir).resolve() # This main loop will be in charge of: # * Getting all the .pyproject files, @@ -189,128 +635,22 @@ if __name__ == "__main__": examples = {} # Create the 'examples' directory if it doesn't exist - if not EXAMPLES_DOC.is_dir(): - EXAMPLES_DOC.mkdir() - - for pyproject_file in EXAMPLES_DIR.glob("**/*.pyproject"): - if pyproject_file.name == "examples.pyproject": - continue - example_dir = pyproject_file.parent - if example_dir.name == "doc": # Dummy pyproject in doc dir (scriptableapplication) - example_dir = example_dir.parent - - parts = example_dir.parts[len(EXAMPLES_DIR.parts):] - - module_name = parts[0] - example_name = parts[-1] - # handling subdirectories besides the module level and the example - extra_names = "" if len(parts) == 2 else "_".join(parts[1:-1]) - - rst_file = f"example_{module_name}_{extra_names}_{example_name}.rst" - - def check_img_ext(i): - EXT = (".png", ".jpg", ".jpeg", ".gif") - if i.suffix in EXT: - return True - return False - - # Check for a 'doc' directory inside the example - has_doc = False - img_doc = None - original_doc_dir = Path(example_dir / "doc") - if original_doc_dir.is_dir(): - has_doc = True - images = [i for i in original_doc_dir.glob("*") if i.is_file() and check_img_ext(i)] - if len(images) > 0: - # We look for an image with the same example_name first, if not, we select the first - image_path = [i for i in images if example_name in str(i)] - if not image_path: - image_path = images[0] - else: - img_doc = image_path[0] - - if module_name not in examples: - examples[module_name] = [] - - examples[module_name].append( - { - "example": example_name, - "module": module_name, - "extra": extra_names, - "rst": rst_file, - "abs_path": str(example_dir), - "has_doc": has_doc, - "img_doc": img_doc, - } - ) - - files = [] - try: - with pyproject_file.open("r", encoding="utf-8") as pyf: - pyproject = json.load(pyf) - # iterate through the list of files in .pyproject and - # check if they exist, before appending to the list. - for f in pyproject["files"]: - if not Path(f).exists: - print(f"example_gallery: {f} listed in {pyproject_file} does not exist") - raise FileNotFoundError - else: - files.append(f) - except (json.JSONDecodeError, KeyError, FileNotFoundError) as e: - print(f"example_gallery: error reading {pyproject_file}: {e}") - raise - - if files: - rst_file_full = EXAMPLES_DOC / rst_file - - with open(rst_file_full, "w", encoding="utf-8") as out_f: - if has_doc: - doc_rst = original_doc_dir / f"{example_name}.rst" - - with open(doc_rst, encoding="utf-8") as doc_f: - content_f = doc_f.read() - - # Copy other files in the 'doc' directory, but - # excluding the main '.rst' file and all the - # directories. - for _f in original_doc_dir.glob("*"): - if _f == doc_rst or _f.is_dir(): - continue - src = _f - dst = EXAMPLES_DOC / _f.name - - resource_written = shutil.copy(src, dst) - if not opt_quiet: - print("Written resource:", resource_written) - else: - content_f = get_header_title(example_dir) - content_f += get_code_tabs(files, pyproject_file.parent) - out_f.write(content_f) - - if not opt_quiet: - print(f"Written: {EXAMPLES_DOC}/{rst_file}") - else: - if not opt_quiet: - print("Empty '.pyproject' file, skipping") - - base_content = dedent( - """\ - .. - This file was auto-generated from the 'pyside-setup/tools/example_gallery' - All editions in this file will be lost. - - |project| Examples - =================== - - A collection of examples are provided with |project| to help new users - to understand different use cases of the module. - - You can find all these examples inside the ``pyside-setup`` on the ``examples`` - directory, or you can access them after installing |pymodname| from ``pip`` - inside the ``site-packages/PySide6/examples`` directory. - - """ - ) + # If it does exist, remove it and create a new one to start fresh + if EXAMPLES_DOC.is_dir(): + shutil.rmtree(EXAMPLES_DOC, ignore_errors=True) + if not opt_quiet: + print("WARNING: Deleted old html directory") + EXAMPLES_DOC.mkdir(exist_ok=True) + + scan_examples_dir(EXAMPLES_DIR) + if options.qt_src_dir: + qt_src = Path(options.qt_src_dir) + if not qt_src.is_dir(): + print("Invalid Qt source directory: {}", file=sys.stderr) + sys.exit(-1) + scan_examples_dir(qt_src.parent / "qtdoc", pyside_example=False) + + examples = sort_examples(examples) # We generate a 'toctree' at the end of the file, to include the new # 'example' rst files, so we get no warnings, and also that users looking @@ -329,12 +669,14 @@ if __name__ == "__main__": # Writing the main example rst file. index_files = [] with open(f"{EXAMPLES_DOC}/index.rst", "w") as f: - f.write(base_content) - for module_name, e in sorted(examples.items()): + f.write(BASE_CONTENT) + for module_name in sorted(examples.keys(), key=module_sort_key): + e = examples.get(module_name) for i in e: - index_files.append(i["rst"]) - f.write(f"{module_name.title()}\n") - f.write(f"{'*' * len(module_name.title())}\n") + index_files.append(i.doc_file) + title = module_title(module_name) + f.write(f"{title}\n") + f.write(f"{'*' * len(title)}\n") f.write(get_module_gallery(e)) f.write("\n\n") f.write(footer_index) diff --git a/tools/missing_bindings-requirements.txt b/tools/missing_bindings-requirements.txt deleted file mode 100644 index bbe8e7ac2..000000000 --- a/tools/missing_bindings-requirements.txt +++ /dev/null @@ -1,7 +0,0 @@ -pyside6 -pyqt5 -beautifulsoup4 -pyqt3d -pyqtchart -pyqtdatavisualization -pyqtwebengine diff --git a/tools/missing_bindings/config.py b/tools/missing_bindings/config.py index a301b9716..ddaf20685 100644 --- a/tools/missing_bindings/config.py +++ b/tools/missing_bindings/config.py @@ -1,7 +1,6 @@ # 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 - modules_to_test = { # 6.0 'QtCore': 'qtcore-module.html', @@ -10,8 +9,8 @@ modules_to_test = { 'QtQml': 'qtqml-module.html', 'QtQuick': 'qtquick-module.html', 'QtQuickWidgets': 'qtquickwidgets-module.html', - 'QtQuickControls2': 'qtquickcontrols2-module.html', - 'QtQuick3D': 'qtquick3d-module.html', + # Broken in 6.5.0 + #'QtQuickControls2': 'qtquickcontrols-module.html', 'QtSql': 'qtsql-module.html', 'QtWidgets': 'qtwidgets-module.html', 'QtConcurrent': 'qtconcurrent-module.html', @@ -20,10 +19,10 @@ modules_to_test = { 'QtOpenGL': 'qtopengl-module.html', 'QtPrintSupport': 'qtprintsupport-module.html', 'QtSvg': 'qtsvg-module.html', + 'QtSvgWidgets': 'qtsvgwidgets-module.html', 'QtUiTools': 'qtuitools-module.html', 'QtXml': 'qtxml-module.html', 'QtTest': 'qttest-module.html', - # 'QtXmlPatterns': 'qtxmlpatterns-module.html', # in Qt5 compat 'Qt3DCore': 'qt3dcore-module.html', 'Qt3DInput': 'qt3dinput-module.html', 'Qt3DLogic': 'qt3dlogic-module.html', @@ -31,6 +30,7 @@ modules_to_test = { 'Qt3DAnimation': 'qt3danimation-module.html', 'Qt3DExtras': 'qt3dextras-module.html', 'QtNetworkAuth': 'qtnetworkauth-module.html', + 'QtStateMachine': 'qtstatemachine-module.html', # 'QtCoAp' -- TODO # 'QtMqtt' -- TODO # 'QtOpcUA' -- TODO @@ -52,17 +52,23 @@ modules_to_test = { 'QtWebEngineQuick': 'qtwebenginequick-module.html', 'QtWebEngineWidgets': 'qtwebenginewidgets-module.html', 'QtWebSockets': 'qtwebsockets-module.html', + 'QtHttpServer': 'qthttpserver-module.html', - # 6.x + # 6.3 #'QtSpeech': 'qtspeech-module.html', 'QtMultimediaWidgets': 'qtmultimediawidgets-module.html', - # 'QtLocation': 'qtlocation-module.html', + 'QtNfc': 'qtnfc-module.html', + 'QtQuick3D': 'qtquick3d-module.html', + + # 6.4 + 'QtPdf': 'qtpdf-module.html', # this include qtpdfwidgets + 'QtSpatialAudio': 'qtspatialaudio-module.html', + + # 6.5 + 'QtSerialBus': 'qtserialbus-module.html', + 'QtTextToSpeech': 'qttexttospeech-module.html', + 'QtLocation': 'qtlocation-module.html', - # Not in 6 - # 'QtScriptTools': 'qtscripttools-module.html', - # 'QtMacExtras': 'qtmacextras-module.html', - # 'QtX11Extras': 'qtx11extras-module.html', - # 'QtWinExtras': 'qtwinextras-module.html', } types_to_ignore = { diff --git a/tools/missing_bindings/main.py b/tools/missing_bindings/main.py index 3c7f68671..4c223050d 100644 --- a/tools/missing_bindings/main.py +++ b/tools/missing_bindings/main.py @@ -26,9 +26,11 @@ from pathlib import Path from bs4 import BeautifulSoup from config import modules_to_test, types_to_ignore +import pandas as pd +import matplotlib.pyplot as plt qt_documentation_website_prefixes = { - "6.3": "https://doc.qt.io/qt-6/", + "6.5": "https://doc.qt.io/qt-6/", "dev": "https://doc-snapshots.qt.io/qt6-dev/", } @@ -57,8 +59,8 @@ def get_parser(): parser.add_argument( "--qt-version", "-v", - default="6.3", - choices=["6.3", "dev"], + default="6.5", + choices=["6.5", "dev"], type=str, dest="version", help="the Qt version to use to check for types", @@ -67,11 +69,17 @@ def get_parser(): "--which-missing", "-w", default="all", - choices=["all", "in-pyqt", "not-in-pyqt"], + choices=["all", "in-pyqt", "not-in-pyqt", "in-pyside-not-in-pyqt"], type=str, dest="which_missing", help="Which missing types to show (all, or just those that are not present in PyQt)", ) + parser.add_argument( + "--plot", + action="store_true", + help="Create module-wise bar plot comparisons for the missing bindings comparisons" + " between Qt, PySide6 and PyQt6", + ) return parser @@ -94,7 +102,7 @@ def wikilog(*pargs, **kw): computed_str = computed_str.replace(":", ":'''") computed_str = f"{computed_str}'''\n" elif style == "error": - computed_str = computed_str.strip('\n') + computed_str = computed_str.strip("\n") computed_str = f"''{computed_str}''\n" elif style == "text_with_link": computed_str = computed_str @@ -118,9 +126,12 @@ if __name__ == "__main__": pyside_package_name = "PySide6" pyqt_package_name = "PyQt6" + data = {"module": [], "qt": [], "pyside": [], "pyqt": []} total_missing_types_count = 0 total_missing_types_count_compared_to_pyqt = 0 total_missing_modules_count = 0 + total_missing_pyqt_types_count = 0 + total_missing_pyqt_modules_count = 0 wiki_file = open("missing_bindings_for_wiki_qt_io.txt", "w") wiki_file.truncate() @@ -199,6 +210,7 @@ if __name__ == "__main__": f"Received error: {e_str}.\n", style="error", ) + total_missing_pyqt_modules_count += 1 # Get C++ class list from documentation page. page = request.urlopen(url) @@ -213,49 +225,85 @@ if __name__ == "__main__": if link_text not in types_to_ignore: types_on_html_page.append(link_text) - wikilog(f"Number of types in {module_name}: {len(types_on_html_page)}", style="bold_colon") + total_qt_types = len(types_on_html_page) + wikilog(f"Number of types in {module_name}: {total_qt_types}", style="bold_colon") - missing_types_count = 0 + missing_pyside_types_count = 0 + missing_pyqt_types_count = 0 missing_types_compared_to_pyqt = 0 missing_types = [] for qt_type in types_on_html_page: + is_present_in_pyqt = False + is_present_in_pyside = False + missing_type = None + + try: + pyqt_qualified_type = f"pyqt_tested_module.{qt_type}" + eval(pyqt_qualified_type) + is_present_in_pyqt = True + except Exception as e: + print(f"{type(e).__name__}: {e}") + missing_pyqt_types_count += 1 + total_missing_pyqt_types_count += 1 + try: pyside_qualified_type = f"pyside_tested_module.{qt_type}" eval(pyside_qualified_type) + is_present_in_pyside = True except Exception as e: print("Failed eval-in pyside qualified types") print(f"{type(e).__name__}: {e}") missing_type = qt_type - missing_types_count += 1 + missing_pyside_types_count += 1 total_missing_types_count += 1 - is_present_in_pyqt = False - try: - pyqt_qualified_type = f"pyqt_tested_module.{qt_type}" - eval(pyqt_qualified_type) + if is_present_in_pyqt: missing_type = f"{missing_type} (is present in PyQt6)" missing_types_compared_to_pyqt += 1 total_missing_types_count_compared_to_pyqt += 1 - is_present_in_pyqt = True - except Exception as e: - print(f"{type(e).__name__}: {e}") + # missing in PySide + if not is_present_in_pyside: if args.which_missing == "all": missing_types.append(missing_type) + message = f"Missing types in PySide (all) {module_name}:" + # missing in PySide and present in pyqt elif args.which_missing == "in-pyqt" and is_present_in_pyqt: missing_types.append(missing_type) + message = f"Missing types in PySide6 (but present in PyQt6) {module_name}:" + # missing in both PyQt and PySide elif args.which_missing == "not-in-pyqt" and not is_present_in_pyqt: missing_types.append(missing_type) + message = f"Missing types in PySide6 (also missing in PyQt6) {module_name}:" + elif ( + args.which_missing == "in-pyside-not-in-pyqt" + and not is_present_in_pyqt + ): + missing_types.append(qt_type) + message = f"Missing types in PyQt6 (but present in PySide6) {module_name}:" if len(missing_types) > 0: - wikilog(f"Missing types in {module_name}:", style="with_newline") + wikilog(message, style="with_newline") missing_types.sort() for missing_type in missing_types: wikilog(missing_type, style="code") wikilog("") + if args.which_missing != "in-pyside-not-in-pyqt": + missing_types_count = missing_pyside_types_count + else: + missing_types_count = missing_pyqt_types_count + + if args.plot: + total_pyside_types = total_qt_types - missing_pyside_types_count + total_pyqt_types = total_qt_types - missing_pyqt_types_count + data["module"].append(module_name) + data["qt"].append(total_qt_types) + data["pyside"].append(total_pyside_types) + data["pyqt"].append(total_pyqt_types) + wikilog(f"Number of missing types: {missing_types_count}", style="bold_colon") - if len(missing_types) > 0: + if len(missing_types) > 0 and args.which_missing != "in-pyside-not-in-pyqt": wikilog( "Number of missing types that are present in PyQt6: " f"{missing_types_compared_to_pyqt}", @@ -265,12 +313,37 @@ if __name__ == "__main__": else: wikilog("", style="end") + if args.plot: + df = pd.DataFrame(data=data, columns=["module", "qt", "pyside", "pyqt"]) + df.set_index("module", inplace=True) + df.plot(kind="bar", title="Qt API Coverage plot") + plt.legend() + plt.xticks(rotation=45) + plt.ylabel("Types Count") + figure = plt.gcf() + figure.set_size_inches(32, 18) # set to full_screen + plt.savefig("missing_bindings_comparison_plot.png", bbox_inches='tight') + print(f"Plot saved in {Path.cwd() / 'missing_bindings_comparison_plot.png'}\n") + wikilog("Summary", style="heading5") - wikilog(f"Total number of missing types: {total_missing_types_count}", style="bold_colon") - wikilog( - "Total number of missing types that are present in PyQt6: " - f"{total_missing_types_count_compared_to_pyqt}", - style="bold_colon", - ) - wikilog(f"Total number of missing modules: {total_missing_modules_count}", style="bold_colon") + + if args.which_missing != "in-pyside-not-in-pyqt": + wikilog(f"Total number of missing types: {total_missing_types_count}", style="bold_colon") + wikilog( + "Total number of missing types that are present in PyQt6: " + f"{total_missing_types_count_compared_to_pyqt}", + style="bold_colon", + ) + wikilog( + f"Total number of missing modules: {total_missing_modules_count}", style="bold_colon" + ) + else: + wikilog( + f"Total number of missing types in PyQt6: {total_missing_pyqt_types_count}", + style="bold_colon", + ) + wikilog( + f"Total number of missing modules in PyQt6: {total_missing_pyqt_modules_count}", + style="bold_colon", + ) wiki_file.close() diff --git a/tools/missing_bindings/requirements.txt b/tools/missing_bindings/requirements.txt index f715bea38..08aa0a024 100644 --- a/tools/missing_bindings/requirements.txt +++ b/tools/missing_bindings/requirements.txt @@ -1,4 +1,6 @@ beautifulsoup4 +pandas +matplotlib # PySide PySide6 diff --git a/tools/scanqtclasses.py b/tools/scanqtclasses.py new file mode 100644 index 000000000..0f87d80bd --- /dev/null +++ b/tools/scanqtclasses.py @@ -0,0 +1,122 @@ +# 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 + +from pathlib import Path +import os +import re +import subprocess +import sys + +"""Scan the Qt C++ headers per module for classes that should be present + in the matching type system and print the missing classes.""" + + +VALUE_TYPE = re.compile(r'^\s*<value-type name="([^"]+)"') + + +OBJECT_TYPE = re.compile(r'^\s*<object-type name="([^"]+)"') + + +def query_qtpaths(keyword): + """Query qtpaths for a keyword.""" + query_cmd = ["qtpaths", "-query", keyword] + output = subprocess.check_output(query_cmd, stderr=subprocess.STDOUT, + universal_newlines=True) + return output.strip() + + +def is_class_exluded(name): + """Check for excluded classes that do not make sense in a typesystem.""" + if len(name) < 2: + return True + if "Iterator" in name or "iterator" in name: + return True + if name.startswith("If") or name.startswith("Is") or name.startswith("When"): + return True + if name[:1].islower(): + return True + if name.startswith("QOpenGLFunctions") and name.endswith("Backend"): + return True + return False + + +def class_from_header_line(line): + """Extract a class name from a C++ header line.""" + def _is_macro(token): + return "EXPORT" in token or "API" in token + + def _fix_class_name(name): + pos = name.find('<') # Some template specialization "class Name<TemplateParam>" + if pos > 0: + name = name[:pos] + if name.endswith(':'): + name = name[:-1] + return name + + if line.startswith('//') or line.endswith(';'): # comment/forward decl + return None + line = line.strip() + if not line.startswith("class ") and not line.startswith("struct "): + return None + tokens = line.split() + pos = 1 + while pos < len(tokens) and _is_macro(tokens[pos]): + pos += 1 + return _fix_class_name(tokens[pos]) if pos < len(tokens) else None + + +def classes_from_header(header): + """Extract classes from C++ header file.""" + result = [] + for line in header.read_text("utf-8").splitlines(): + name = class_from_header_line(line) + if name and not is_class_exluded(name): + result.append(name) + return sorted(result) + + +def classes_from_typesystem(typesystem): + """Extract classes from typesystem XML file.""" + result = [] + for line in typesystem.read_text("utf-8").splitlines(): + match = VALUE_TYPE.search(line) or OBJECT_TYPE.search(line) + if match: + result.append(match.group(1)) + return sorted(result) + + +def check_classes(qt_module_inc_dir, pyside_dir): + """Check classes of a module.""" + module_name = qt_module_inc_dir.name + sys.stderr.write(f"Checking {module_name} ") + cpp_classes = [] + typesystem_classes = [] + for header in qt_module_inc_dir.glob("q*.h"): + if not header.name.endswith("_p.h"): + cpp_classes.extend(classes_from_header(header)) + for typesystem in pyside_dir.glob("*.xml"): + typesystem_classes.extend(classes_from_typesystem(typesystem)) + + cpp_count = len(cpp_classes) + typesystem_count = len(typesystem_classes) + sys.stderr.write(f"found {cpp_count} C++ / {typesystem_count} typesystem classes") + if cpp_count <= typesystem_count: + sys.stderr.write(" ok\n") + else: + sys.stderr.write(f", {cpp_count-typesystem_count} missing\n") + for cpp_class in cpp_classes: + if cpp_class not in typesystem_classes: + wrapper_name = cpp_class.lower() + "_wrapper.cpp" + print(f"{module_name}:{cpp_class}:{wrapper_name}") + + +if __name__ == '__main__': + qt_version = query_qtpaths("QT_VERSION") + qt_inc_dir = Path(query_qtpaths("QT_INSTALL_HEADERS")) + print(f"Qt {qt_version} at {os.fspath(qt_inc_dir.parent)}", file=sys.stderr) + + dir = Path(__file__).parents[1].resolve() + for module_dir in (dir / "sources" / "pyside6" / "PySide6").glob("Qt*"): + qt_module_inc_dir = qt_inc_dir / module_dir.name + if qt_module_inc_dir.is_dir(): + check_classes(qt_module_inc_dir, module_dir) diff --git a/tools/snippets_translate/converter.py b/tools/snippets_translate/converter.py index 93aab199f..d45bf277f 100644 --- a/tools/snippets_translate/converter.py +++ b/tools/snippets_translate/converter.py @@ -36,13 +36,24 @@ RETURN_TYPE_PATTERN = re.compile(r"^[a-zA-Z0-9]+(<.*?>)? [\w]+::[\w\*\&]+\(.*\)$ FUNCTION_PATTERN = re.compile(r"^[a-zA-Z0-9]+(<.*?>)? [\w\*\&]+\(.*\)$") ITERATOR_PATTERN = re.compile(r"(std::)?[\w]+<[\w]+>::(const_)?iterator") SCOPE_PATTERN = re.compile(r"[\w]+::") +SWITCH_PATTERN = re.compile(r"^\s*switch\s*\(([a-zA-Z0-9_\.]+)\)\s*{.*$") +CASE_PATTERN = re.compile(r"^(\s*)case\s+([a-zA-Z0-9_:\.]+):.*$") +DEFAULT_PATTERN = re.compile(r"^(\s*)default:.*$") QUALIFIERS = {"public:", "protected:", "private:", "public slots:", "protected slots:", "private slots:", "signals:"} +FUNCTION_QUALIFIERS = ["virtual ", " override", "inline ", " noexcept"] + + +switch_var = None +switch_branch = 0 + + def snippet_translate(x): + global switch_var, switch_branch ## Cases which are not C++ ## TODO: Maybe expand this with lines that doesn't need to be translated @@ -52,7 +63,8 @@ def snippet_translate(x): ## General Rules # Remove ';' at the end of the lines - if x.endswith(";"): + has_semicolon = x.endswith(";") + if has_semicolon: x = x[:-1] # Remove lines with only '{' or '}' @@ -136,6 +148,25 @@ def snippet_translate(x): if "throw" in x: x = handle_keywords(x, "throw", "raise") + switch_match = SWITCH_PATTERN.match(x) + if switch_match: + switch_var = switch_match.group(1) + switch_branch = 0 + return "" + + switch_match = CASE_PATTERN.match(x) + if switch_match: + indent = switch_match.group(1) + value = switch_match.group(2).replace("::", ".") + cond = "if" if switch_branch == 0 else "elif" + switch_branch += 1 + return f"{indent}{cond} {switch_var} == {value}:" + + switch_match = DEFAULT_PATTERN.match(x) + if switch_match: + indent = switch_match.group(1) + return f"{indent}else:" + # handle 'void Class::method(...)' and 'void method(...)' if VOID_METHOD_PATTERN.search(x): x = handle_void_functions(x) @@ -233,7 +264,8 @@ def snippet_translate(x): # At the end we skip methods with the form: # QStringView Message::body() # to threat them as methods. - if (VAR1_PATTERN.search(xs) + if (has_semicolon and VAR1_PATTERN.search(xs) + and not ([f for f in FUNCTION_QUALIFIERS if f in x]) and xs.split()[0] not in ("def", "return", "and", "or") and not VAR2_PATTERN.search(xs) and ("{" not in x and "}" not in x)): @@ -264,7 +296,10 @@ def snippet_translate(x): # so we need to add '()' at the end if it's just a word # with only alpha numeric content if VAR4_PATTERN.search(xs) and not xs.endswith(")"): - x = f"{x.rstrip()}()" + v = x.rstrip() + if (not v.endswith(" True") and not v.endswith(" False") + and not v.endswith(" None")): + x = f"{v}()" return dstrip(x) # For constructors, that we now the shape is: diff --git a/tools/snippets_translate/handlers.py b/tools/snippets_translate/handlers.py index 1af97ff64..34e969a62 100644 --- a/tools/snippets_translate/handlers.py +++ b/tools/snippets_translate/handlers.py @@ -30,8 +30,13 @@ ARRAY_DECLARATION_PATTERN = re.compile(r"^[a-zA-Z0-9\<\>]+ ([\w\*]+) *\[?\]?") RETURN_TYPE_PATTERN = re.compile(r"^ *[a-zA-Z0-9]+ [\w]+::([\w\*\&]+\(.*\)$)") CAPTURE_PATTERN = re.compile(r"^ *([a-zA-Z0-9]+) ([\w\*\&]+\(.*\)$)") USELESS_QT_CLASSES_PATTERNS = [ - re.compile(r"QLatin1String\((.*)\)"), - re.compile(r"QLatin1Char\((.*)\)") + re.compile(r'QLatin1StringView\(("[^"]*")\)'), + re.compile(r'QLatin1String\(("[^"]*")\)'), + re.compile(r'QString\.fromLatin1\(("[^"]*")\)'), + re.compile(r"QLatin1Char\(('[^']*')\)"), + re.compile(r'QStringLiteral\(("[^"]*")\)'), + re.compile(r'QString\.fromUtf8\(("[^"]*")\)'), + re.compile(r'u("[^"]*")_s') ] COMMENT1_PATTERN = re.compile(r" *# *[\w\ ]+$") COMMENT2_PATTERN = re.compile(r" *# *(.*)$") @@ -510,10 +515,13 @@ def handle_functions(x): def handle_useless_qt_classes(x): for c in USELESS_QT_CLASSES_PATTERNS: - content = c.search(x) - if content: - x = x.replace(content.group(0), content.group(1)) - return x + while True: + match = c.search(x) + if match: + x = x[0:match.start()] + match.group(1) + x[match.end():] + else: + break + return x.replace('"_L1', '"').replace("u'", "'") def handle_new(x): @@ -547,13 +555,19 @@ def handle_new(x): INSTANCE_PMF_RE = re.compile(r"&?(\w+),\s*&\w+::(\w+)") -CONNECT_RE = re.compile(r"^(\s*)(QObject::)?connect\((\w+\.\w+),\s*") +CONNECT_RE = re.compile(r"^(\s*)(QObject::)?connect\(([A-Za-z0-9_\.]+),\s*") -def handle_qt_connects(line): - if not INSTANCE_PMF_RE.search(line): +def handle_qt_connects(line_in): + if not INSTANCE_PMF_RE.search(line_in): return None # 1st pass, "fontButton, &QAbstractButton::clicked" -> "fontButton.clicked" + + is_connect = "connect(" in line_in + line = line_in + # Remove any smart pointer access, etc in connect statements + if is_connect: + line = line.replace(".get()", "").replace(".data()", "").replace("->", ".") last_pos = 0 result = "" for match in INSTANCE_PMF_RE.finditer(line): @@ -567,6 +581,9 @@ def handle_qt_connects(line): result += f"{instance}.{member_fun}" result += line[last_pos:] + if not is_connect: + return result + # 2nd pass, reorder connect. connect_match = CONNECT_RE.match(result) if not connect_match: diff --git a/tools/snippets_translate/main.py b/tools/snippets_translate/main.py index 12b6e54f1..01ea06c5e 100644 --- a/tools/snippets_translate/main.py +++ b/tools/snippets_translate/main.py @@ -213,7 +213,9 @@ def get_snippet_override(start_id: str, rel_path: str) -> List[str]: return overriden_snippet_lines(lines, start_id) -def _get_snippets(lines: List[str], pattern: re.Pattern) -> Dict[str, List[str]]: +def _get_snippets(lines: List[str], + comment: str, + pattern: re.Pattern) -> Dict[str, List[str]]: """Helper to extract (potentially overlapping) snippets from a C++ file indicated by pattern ("//! [1]") and return them as a dict by <id>.""" snippets: Dict[str, List[str]] = {} @@ -231,8 +233,12 @@ def _get_snippets(lines: List[str], pattern: re.Pattern) -> Dict[str, List[str]] start_id = start_ids.pop(0) if start_id in done_snippets: continue + + # Reconstruct a single ID line to avoid repetitive ID lines + # by consecutive snippets with multi-ID lines like "//! [1] [2]" + id_line = f"{comment}! [{start_id}]" done_snippets.append(start_id) - snippet = [line] # The snippet starts with this id + snippet = [id_line] # The snippet starts with this id # Find the end of the snippet j = i @@ -246,6 +252,7 @@ def _get_snippets(lines: List[str], pattern: re.Pattern) -> Dict[str, List[str]] # Check if the snippet is complete if start_id in get_snippet_ids(l, pattern): # End of snippet + snippet[len(snippet) - 1] = id_line snippets[start_id] = snippet break @@ -260,7 +267,7 @@ def get_python_example_snippet_override(start_id: str, rel_path: str) -> List[st return [] path, id = value file_lines = path.read_text().splitlines() - snippet_dict = _get_snippets(file_lines, PYTHON_SNIPPET_PATTERN) + snippet_dict = _get_snippets(file_lines, '#', PYTHON_SNIPPET_PATTERN) lines = snippet_dict.get(id) if not lines: raise RuntimeError(f'Snippet "{id}" not found in "{os.fspath(path)}"') @@ -271,7 +278,7 @@ def get_python_example_snippet_override(start_id: str, rel_path: str) -> List[st def get_snippets(lines: List[str], rel_path: str) -> List[List[str]]: """Extract (potentially overlapping) snippets from a C++ file indicated by '//! [1]'.""" - result = _get_snippets(lines, CPP_SNIPPET_PATTERN) + result = _get_snippets(lines, '//', CPP_SNIPPET_PATTERN) id_list = result.keys() for snippet_id in id_list: # Check file overrides and example overrides @@ -371,6 +378,7 @@ def translate_file(file_path, final_path, qt_path, debug, write): target_file.parent.mkdir(parents=True, exist_ok=True) with target_file.open("w", encoding="utf-8") as out_f: + out_f.write("//! [AUTO]\n\n") out_f.write(license_header) out_f.write("\n\n") diff --git a/tools/snippets_translate/module_classes.py b/tools/snippets_translate/module_classes.py index 4992e170b..df4c7557c 100644 --- a/tools/snippets_translate/module_classes.py +++ b/tools/snippets_translate/module_classes.py @@ -532,6 +532,7 @@ module_classes = { "QAccessibleEvent", "QAccessibleInterface", "QAccessibleObject", + "QAccessibleSelectionInterface", "QAccessibleStateChangeEvent", "QAccessibleTableCellInterface", "QAccessibleTableModelChangeEvent", diff --git a/tools/snippets_translate/tests/test_converter.py b/tools/snippets_translate/tests/test_converter.py index 4cf614d1e..084cc8a6d 100644 --- a/tools/snippets_translate/tests/test_converter.py +++ b/tools/snippets_translate/tests/test_converter.py @@ -4,6 +4,11 @@ from converter import snippet_translate as st +def multi_st(lines): + result = [st(l) for l in lines.split("\n")] + return "\n".join(result) + + def test_comments(): assert st("// This is a comment") == "# This is a comment" assert st("// double slash // inside") == "# double slash // inside" @@ -112,6 +117,21 @@ def test_double_colon(): assert st("this, &MyClass::slotError);") == "self.slotError)" +def test_connects(): + assert ( + st("connect(button, &QPushButton::clicked, this, &MyClass::slotClicked);") + == "button.clicked.connect(self.slotClicked)" + ) + assert ( + st("connect(m_ui->button, &QPushButton::clicked, this, &MyClass::slotClicked);") + == "m_ui.button.clicked.connect(self.slotClicked)" + ) + assert ( + st("connect(button.get(), &QPushButton::clicked, this, &MyClass::slotClicked);") + == "button.clicked.connect(self.slotClicked)" + ) + + def test_cout_endl(): assert st("cout << 'hello' << 'world' << endl") == "print('hello', 'world')" assert st(" cout << 'hallo' << 'welt' << endl") == " print('hallo', 'welt')" @@ -146,19 +166,25 @@ def test_cout_endl(): def test_variable_declaration(): assert st("QLabel label;") == "label = QLabel()" - assert st('QLabel label("Hello")') == 'label = QLabel("Hello")' + assert st('QLabel label("Hello");') == 'label = QLabel("Hello")' assert st("Widget w;") == "w = Widget()" assert st('QLabel *label = new QLabel("Hello");') == 'label = QLabel("Hello")' assert st('QLabel label = a_function("Hello");') == 'label = a_function("Hello")' assert st('QString a = "something";') == 'a = "something"' assert st("int var;") == "var = int()" assert st("float v = 0.1;") == "v = 0.1" - assert st("QSome<thing> var") == "var = QSome()" + assert st("QSome<thing> var;") == "var = QSome()" assert st("QQueue<int> queue;") == "queue = QQueue()" assert st("QVBoxLayout *layout = new QVBoxLayout;") == "layout = QVBoxLayout()" assert st("QPointer<QLabel> label = new QLabel;") == "label = QLabel()" assert st("QMatrix4x4 matrix;") == "matrix = QMatrix4x4()" assert st("QList<QImage> collage =") == "collage =" + assert st("bool b = true;") == "b = True" + assert st("Q3DBars *m_graph = nullptr;") == "m_graph = None" + # Do not fall for member function definitions + assert st("Q3DBars *Graph::bars() const") == "Q3DBars Graph.bars()" + # Do not fall for member function declarations + assert st("virtual Q3DBars *bars();") == "virtual Q3DBars bars()" def test_for(): @@ -368,7 +394,12 @@ def test_ternary_operator(): def test_useless_qt_classes(): assert st('result += QLatin1String("; ");') == 'result += "; "' + assert st('result += QString::fromLatin1("; ");') == 'result += "; "' + assert ( + st('result = QStringLiteral("A") + QStringLiteral("B");') + == 'result = "A" + "B"') assert st("<< QLatin1Char('\0') << endl;") == "print('\0')" + assert st('result = u"A"_s;') == 'result = "A"' def test_special_cases(): @@ -416,6 +447,35 @@ def test_lambdas(): pass +def test_switch_case(): + source = """switch (v) { +case 1: + f1(); + break; +case ClassName::EnumValue: + f2(); + break; +default: + f3(); + break; +} +""" + expected = """ +if v == 1: + f1() + break +elif v == ClassName.EnumValue: + f2() + break +else: + f3() + break + +""" + + assert multi_st(source) == expected + + def test_std_function(): # std::function<QImage(const QImage &)> scale = [](const QImage &img) { pass diff --git a/tools/snippets_translate/tests/test_snippets.py b/tools/snippets_translate/tests/test_snippets.py index 3c29fcbf5..84897d815 100644 --- a/tools/snippets_translate/tests/test_snippets.py +++ b/tools/snippets_translate/tests/test_snippets.py @@ -4,6 +4,9 @@ from main import _get_snippets, get_snippet_ids, CPP_SNIPPET_PATTERN +C_COMMENT = "//" + + def test_stacking(): lines = [ "//! [A] //! [B] ", @@ -12,7 +15,7 @@ def test_stacking(): "//! [C] //! [A] ", "//! [B] //! [D] //! [E]", ] - snippets = _get_snippets(lines, CPP_SNIPPET_PATTERN) + snippets = _get_snippets(lines, C_COMMENT, CPP_SNIPPET_PATTERN) assert len(snippets) == 5 snippet_a = snippets["A"] @@ -41,7 +44,7 @@ def test_nesting(): "//! [C]", "//! [B]", ] - snippets = _get_snippets(lines, CPP_SNIPPET_PATTERN) + snippets = _get_snippets(lines, C_COMMENT, CPP_SNIPPET_PATTERN) assert len(snippets) == 3 snippet_a = snippets["A"] @@ -58,24 +61,27 @@ def test_nesting(): def test_overlapping(): + a_id = "//! [A]" + b_id = "//! [B]" lines = [ "pretext", - "//! [A]", + a_id, "l1", "//! [C]", "//! [A] //! [B]", "l2", "l3 // Comment", - "//! [B]", + b_id, "posttext", "//! [C]", ] - snippets = _get_snippets(lines, CPP_SNIPPET_PATTERN) + snippets = _get_snippets(lines, C_COMMENT, CPP_SNIPPET_PATTERN) assert len(snippets) == 3 + # Simple snippet ID lines are generated snippet_a = snippets["A"] assert len(snippet_a) == 4 - assert snippet_a == lines[1:5] + assert snippet_a == lines[1:4] + [a_id] snippet_c = snippets["C"] assert len(snippet_c) == 7 @@ -83,31 +89,35 @@ def test_overlapping(): snippet_b = snippets["B"] assert len(snippet_b) == 4 - assert snippet_b == lines[4:8] + assert snippet_b == [b_id] + lines[5:8] def test_snippets(): + a_id = "//! [A]" + b_id = "//! [B]" + lines = [ "pretext", - "//! [A]", + a_id, "l1", "//! [A] //! [B]", "l2", "l3 // Comment", - "//! [B]", + b_id, "posttext" ] - snippets = _get_snippets(lines, CPP_SNIPPET_PATTERN) + snippets = _get_snippets(lines, C_COMMENT, CPP_SNIPPET_PATTERN) assert len(snippets) == 2 snippet_a = snippets["A"] + assert len(snippet_a) == 3 - assert snippet_a == lines[1:4] + assert snippet_a == lines[1:3] + [a_id] snippet_b = snippets["B"] assert len(snippet_b) == 4 - assert snippet_b == lines[3:7] + assert snippet_b == [b_id] + lines[4:7] def test_snippet_ids(): |