First upload, 18 controller version

This commit is contained in:
2026-04-14 15:23:56 +02:00
commit 8c55001a1c
3810 changed files with 764061 additions and 0 deletions

View File

@@ -0,0 +1,67 @@
# 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 __future__ import annotations
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())
DEFAULT_IGNORE_DIRS = {"site-packages", "deployment", ".git", ".qtcreator", "build", "dist",
"tests", "doc", "docs", "examples", ".vscode", "__pycache__"}
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
""")
# plugins to be removed from the --include-qt-plugins option because these plugins
# don't exist in site-package under PySide6/Qt/plugins
PLUGINS_TO_REMOVE = ["accessiblebridge", "platforms/darwin", "networkaccess", "scenegraph"]
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

View File

@@ -0,0 +1,63 @@
# 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 __future__ import annotations
import json
import subprocess
import sys
from pathlib import Path
from functools import lru_cache
from . import DEFAULT_IGNORE_DIRS
"""
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
@lru_cache
def run_qmlimportscanner(project_dir: Path, dry_run: bool):
"""
Runs pyside6-qmlimportscanner to find all the imported qml modules in project_dir
"""
qml_modules = []
cmd = ["pyside6-qmlimportscanner", "-rootPath", str(project_dir)]
for ignore_dir in DEFAULT_IGNORE_DIRS:
cmd.extend(["-exclude", ignore_dir])
if dry_run:
run_command(command=cmd, dry_run=True)
# 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

View File

@@ -0,0 +1,532 @@
# 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 __future__ import annotations
import sys
import configparser
import logging
import tempfile
import warnings
from configparser import ConfigParser
from pathlib import Path
from enum import Enum
from project_lib import ProjectData, DesignStudioProject, resolve_valid_project_file
from . import (DEFAULT_APP_ICON, DEFAULT_IGNORE_DIRS, 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] Updating config file {self.config_file}")
# This section of code is done to preserve the formatting of the original deploy.spec
# file where there is blank line before the comments
with tempfile.NamedTemporaryFile('w+', delete=False) as temp_file:
self.parser.write(temp_file, space_around_delimiters=True)
temp_file_path = temp_file.name
# Read the temporary file and write back to the original file with blank lines before
# comments
with open(temp_file_path, 'r') as temp_file, open(self.config_file, 'w') as config_file:
previous_line = None
for line in temp_file:
if (line.lstrip().startswith('#') and previous_line is not None
and not previous_line.lstrip().startswith('#')):
config_file.write('\n')
config_file.write(line)
previous_line = line
# Clean up the temporary file
Path(temp_file_path).unlink()
def set_value(self, section: str, key: str, new_value: str, raise_warning: bool = True) -> None:
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 not raise_warning:
return
logging.warning(f"[DEPLOY] Set key '{key}': Key does not exist in section '{section}'")
except configparser.NoSectionError:
if not raise_warning:
return
logging.warning(f"[DEPLOY] Section '{section}' does not exist")
def get_value(self, section: str, key: str, ignore_fail: bool = False) -> str | None:
try:
return self.parser.get(section, key)
except configparser.NoOptionError:
if ignore_fail:
return None
logging.warning(f"[DEPLOY] Get key '{key}': Key does not exist in section {section}")
except configparser.NoSectionError:
if ignore_fail:
return None
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,
name: 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()
self.source_file = Path(
self.set_or_fetch(property_value=source_file, property_key="input_file")
).resolve()
self.python_path = Path(
self.set_or_fetch(
property_value=python_exe,
property_key="python_path",
property_group="python",
)
)
self.title = self.set_or_fetch(property_value=name, property_key="title")
config_icon = self.get_value("app", "icon")
if config_icon:
self._icon = str(Path(config_icon).resolve())
else:
self.icon = DEFAULT_APP_ICON
proj_dir = self.get_value("app", "project_dir")
if proj_dir:
self._project_dir = Path(proj_dir).resolve()
else:
self.project_dir = self._find_project_dir()
exe_directory = self.get_value("app", "exec_directory")
if exe_directory:
self._exe_dir = Path(exe_directory).absolute()
else:
self.exe_dir = self._find_exe_dir()
self._project_file = None
proj_file = self.get_value("app", "project_file")
if proj_file:
self._project_file = self.project_dir / proj_file
else:
proj_file = self._find_project_file()
if proj_file:
self.project_file = proj_file
self.project_data = None
if self.project_file and self.project_file.exists():
self.project_data = ProjectData(project_file=self.project_file)
self._qml_files = []
# Design Studio projects include the qml files using Qt resources
if source_file and not DesignStudioProject.is_ds_project(source_file):
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.qml_files = self._find_qml_files()
self._excluded_qml_plugins = []
excl_qml_plugins = self.get_value("qt", "excluded_qml_plugins")
if excl_qml_plugins and self.existing_config_file:
self._excluded_qml_plugins = excl_qml_plugins.split(",")
else:
self.excluded_qml_plugins = self._find_excluded_qml_plugins()
self._generated_files_path = self.source_file.parent / "deployment"
self.modules = []
def set_or_fetch(self, property_value, property_key, property_group="app") -> str:
"""
If a new property value is provided, store it in the config file
Otherwise return the existing value in the config file.
Raise an exception if neither are available.
:param property_value: The value to set if provided.
:param property_key: The configuration key.
:param property_group: The configuration group (default is "app").
:return: The configuration value.
:raises RuntimeError: If no value is provided and no existing value is found.
"""
existing_value = self.get_value(property_group, property_key)
if property_value:
self.set_value(property_group, property_key, str(property_value))
return property_value
if existing_value:
return existing_value
raise RuntimeError(
f"[DEPLOY] No value for {property_key} specified in config file or as cli option"
)
@property
def dry_run(self) -> bool:
return self._dry_run
@property
def generated_files_path(self) -> Path:
return self._generated_files_path
@property
def qml_files(self) -> list[Path]:
return self._qml_files
@qml_files.setter
def qml_files(self, qml_files: list[Path]):
self._qml_files = qml_files
qml_files = [str(file.absolute().relative_to(self.project_dir.absolute()))
if file.absolute().is_relative_to(self.project_dir) else str(file.absolute())
for file in self.qml_files]
qml_files.sort()
self.set_value("qt", "qml_files", ",".join(qml_files))
@property
def project_dir(self) -> Path:
return self._project_dir
@project_dir.setter
def project_dir(self, project_dir: Path) -> None:
rel_path = (
project_dir.relative_to(self.config_file.parent)
if project_dir.is_relative_to(self.config_file.parent)
else project_dir
)
self._project_dir = project_dir
self.set_value("app", "project_dir", str(rel_path))
@property
def project_file(self) -> Path:
return self._project_file
@project_file.setter
def project_file(self, project_file: Path):
self._project_file = project_file
self.set_value("app", "project_file", str(project_file.relative_to(self.project_dir)))
@property
def title(self) -> str:
return self._title
@title.setter
def title(self, title: str):
self._title = title
@property
def icon(self) -> str:
return self._icon
@icon.setter
def icon(self, icon: str):
self._icon = icon
self.set_value("app", "icon", icon)
@property
def source_file(self) -> Path:
return self._source_file
@source_file.setter
def source_file(self, source_file: Path) -> None:
rel_path = (
source_file.relative_to(self.config_file.parent)
if source_file.is_relative_to(self.config_file.parent)
else source_file
)
self._source_file = source_file
self.set_value("app", "input_file", str(rel_path))
@property
def python_path(self) -> Path:
return self._python_path
@python_path.setter
def python_path(self, python_path: Path):
self._python_path = python_path
@property
def extra_args(self) -> str:
return self.get_value("nuitka", "extra_args")
@extra_args.setter
def extra_args(self, extra_args: str):
self.set_value("nuitka", "extra_args", extra_args)
@property
def excluded_qml_plugins(self) -> list[str]:
return self._excluded_qml_plugins
@excluded_qml_plugins.setter
def excluded_qml_plugins(self, excluded_qml_plugins: list[str]):
self._excluded_qml_plugins = excluded_qml_plugins
if excluded_qml_plugins: # check required for Android
excluded_qml_plugins.sort()
self.set_value("qt", "excluded_qml_plugins", ",".join(excluded_qml_plugins))
@property
def exe_dir(self) -> Path:
return self._exe_dir
@exe_dir.setter
def exe_dir(self, exe_dir: Path):
self._exe_dir = exe_dir
self.set_value("app", "exec_directory", str(exe_dir))
@property
def modules(self) -> list[str]:
return self._modules
@modules.setter
def modules(self, modules: list[str]):
self._modules = modules
modules.sort()
self.set_value("qt", "modules", ",".join(modules))
def _find_qml_files(self):
"""
Fetches all the qml_files in the folder and sets them if the
field qml_files is empty in the config_file
"""
if self.project_data:
qml_files = [(self.project_dir / str(qml_file)) for qml_file in
self.project_data.qml_files]
for sub_project_file in self.project_data.sub_projects_files:
qml_files.extend([self.project_dir / str(qml_file) for qml_file in
ProjectData(project_file=sub_project_file).qml_files])
else:
# Filter out files from DEFAULT_IGNORE_DIRS
qml_files = [
file for file in self.project_dir.glob("**/*.qml")
if all(part not in file.parts for part in DEFAULT_IGNORE_DIRS)
]
if len(qml_files) > 500:
warnings.warn(
"You seem to include a lot of QML files from "
f"{self.project_dir}. This can lead to errors in deployment."
)
return qml_files
def _find_project_dir(self) -> Path:
if DesignStudioProject.is_ds_project(self.source_file):
return DesignStudioProject(self.source_file).project_dir
# There is no other way to find the project_dir than assume it is the parent directory
# of source_file
return self.source_file.parent
def _find_project_file(self) -> Path | None:
if not self.source_file:
raise RuntimeError("[DEPLOY] Source file not set in config file")
if DesignStudioProject.is_ds_project(self.source_file):
pyproject_location = self.source_file.parent
else:
pyproject_location = self.project_dir
try:
return resolve_valid_project_file(pyproject_location)
except ValueError as e:
logging.warning(f"[DEPLOY] Unable to resolve a valid project file. Proceeding without a"
f" project file. Details:\n{e}.")
return None
def _find_excluded_qml_plugins(self) -> list[str] | None:
if not self.qml_files and not DesignStudioProject.is_ds_project(self.source_file):
return None
self.qml_modules = set(run_qmlimportscanner(project_dir=self.project_dir,
dry_run=self.dry_run))
excluded_qml_plugins = EXCLUDED_QML_PLUGINS.difference(self.qml_modules)
# sorting needed for dry_run testing
return sorted(excluded_qml_plugins)
def _find_exe_dir(self) -> Path:
if self.project_dir == Path.cwd():
return self.project_dir.relative_to(Path.cwd())
return self.project_dir
def _find_pysidemodules(self) -> list[str]:
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 {modules}")
return modules
def _find_qtquick_modules(self) -> list[str]:
"""Identify if QtQuick is used in QML files and add them as dependency
"""
extra_modules = []
if not self.qml_modules and self.qml_files:
self.qml_modules = set(run_qmlimportscanner(project_dir=self.project_dir,
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")
return 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", name: str = None):
super().__init__(config_file, source_file, python_exe, dry_run, existing_config_file,
extra_ignore_dirs, name=name)
self.dependency_reader = QtDependencyReader(dry_run=self.dry_run)
modules = self.get_value("qt", "modules")
if modules:
self._modules = modules.split(",")
else:
modules = self._find_pysidemodules()
modules += self._find_qtquick_modules()
modules += self._find_dependent_qt_modules(modules=modules)
# remove duplicates
self.modules = list(set(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.permissions = self._find_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
if DesignStudioProject.is_ds_project(self.source_file):
ds_project = DesignStudioProject(self.source_file)
if not ds_project.compiled_resources_available():
raise RuntimeError(f"[DEPLOY] Compiled resources file not found: "
f"{ds_project.compiled_resources_file.absolute()}. "
f"Build the project using 'pyside6-project build' or compile "
f"the resources manually using pyside6-rcc")
@property
def qt_plugins(self) -> list[str]:
return self._qt_plugins
@qt_plugins.setter
def qt_plugins(self, qt_plugins: list[str]):
self._qt_plugins = qt_plugins
qt_plugins.sort()
self.set_value("qt", "plugins", ",".join(qt_plugins))
@property
def permissions(self) -> list[str]:
return self._permissions
@permissions.setter
def permissions(self, permissions: list[str]):
self._permissions = permissions
permissions.sort()
self.set_value("nuitka", "macos.permissions", ",".join(permissions))
@property
def mode(self) -> NuitkaMode:
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, modules: list[str]) -> list[str]:
"""
Given pysidedeploy_config.modules, find all the other dependent Qt modules.
"""
all_modules = set(modules)
if not self.dependency_reader.lib_reader:
warnings.warn(f"[DEPLOY] Unable to find {self.dependency_reader.lib_reader_name}. This "
f"tool helps to find the Qt module dependencies of the application. "
f"Skipping checking for dependencies.", category=RuntimeWarning)
return []
for module_name in modules:
self.dependency_reader.find_dependencies(module=module_name, used_modules=all_modules)
return list(all_modules)
def _find_permissions(self) -> list[str]:
"""
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])
return permissions

View File

@@ -0,0 +1,98 @@
[app]
# Title of your application
title = pyside_app_demo
# Project root directory. Default: The parent directory of input_file
project_dir =
# Source file entry point path. Default: main.py
input_file =
# Directory where the executable output is generated
exec_directory =
# Path to the project file relative to project_dir
project_file =
# Application icon
icon =
[python]
# Python path
python_path =
# Python packages to install
packages = Nuitka==2.7.11
# Buildozer: for deploying Android application
android_packages = buildozer==1.5.0,cython==0.29.33
[qt]
# Paths to required QML files. Comma separated
# Normally all the QML files required by the project are added automatically
# Design Studio projects include the QML files using Qt resources
qml_files =
# Excluded qml plugin binaries
excluded_qml_plugins =
# Qt modules used. Comma separated
modules =
# Qt plugins used by the application. Only relevant for desktop deployment
# For Qt plugins used in Android application see [android][plugins]
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. Comma separated
# eg: NSCameraUsageDescription:CameraAccess
macos.permissions =
# Mode of using Nuitka. Accepts standalone or onefile. Default: onefile
mode = onefile
# Specify any extra nuitka arguments
# eg: extra_args = --show-modules --follow-stdlib
extra_args = --quiet --noinclude-qt-translations
[buildozer]
# Build mode
# Possible values: [release, debug]
# Release creates a .aab, while debug creates a .apk
mode = debug
# Path to PySide6 and shiboken6 recipe dir
recipe_dir =
# Path to extra Qt Android .jar files 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 at app startup. Comma separated.
local_libs =
# Architecture of deployed platform
# Possible values: ["aarch64", "armv7a", "i686", "x86_64"]
arch =

View File

@@ -0,0 +1,337 @@
# 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 __future__ import annotations
import ast
import re
import os
import site
import json
import warnings
import logging
import shutil
import sys
from pathlib import Path
from functools import lru_cache
from . import IMPORT_WARNING_PYSIDE, DEFAULT_IGNORE_DIRS, run_command
@lru_cache(maxsize=None)
def get_py_files(project_dir: Path, extra_ignore_dirs: tuple[Path] = None, project_data=None):
"""Finds and returns all the Python files in the project
"""
py_candidates = []
ignore_dirs = DEFAULT_IGNORE_DIRS.copy()
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 = []
missing_files = []
for file in candidates:
py_file = file.parent / f"{candidate_type}_{file.stem}.py"
if py_file.exists():
possible_py_candidates.append(py_file)
else:
missing_files.append((str(file), str(py_file)))
if missing_files:
missing_details = "\n".join(
f"{candidate_type.upper()} file: {src} -> Missing Python file: {dst}"
for src, dst in missing_files
)
warnings.warn(
f"[DEPLOY] The following {candidate_type} files do not have corresponding "
f"Python files:\n {missing_details}",
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, "rc")
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.update(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)
if extra_ignore_dirs is not None:
extra_ignore_dirs = tuple(extra_ignore_dirs)
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>.*)")
@lru_cache
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 and 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)
if extra_ignore_dirs is not None:
extra_ignore_dirs = tuple(extra_ignore_dirs)
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
"""
# PYSIDE-2785 consider dist-packages for Debian based systems
for possible_site_package in site.getsitepackages():
if possible_site_package.endswith(("site-packages", "dist-packages")):
self.pyside_install_dir = Path(possible_site_package) / "PySide6"
if self.pyside_install_dir.exists():
break
if not self.pyside_install_dir:
print("Unable to find where PySide6 is installed. 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)

View File

@@ -0,0 +1,106 @@
# Copyright (C) 2023 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR LGPL-3.0-only OR GPL-2.0-only OR GPL-3.0-only
from __future__ import annotations
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.
Parameters:
config (Config): The configuration object containing paths and settings.
is_android (bool): Flag indicating if the cleanup is for an Android project. Default is False.
"""
if config.generated_files_path.exists():
try:
shutil.rmtree(config.generated_files_path)
logging.info("[DEPLOY] Deployment directory purged")
except PermissionError as e:
print(f"{type(e).__name__}: {e}")
logging.warning(f"[DEPLOY] Could not delete {config.generated_files_path}")
if is_android:
buildozer_spec: Path = config.project_dir / "buildozer.spec"
if buildozer_spec.exists():
try:
buildozer_spec.unlink()
logging.info(f"[DEPLOY] {str(buildozer_spec)} removed")
except PermissionError as e:
print(f"{type(e).__name__}: {e}")
logging.warning(f"[DEPLOY] Could not delete {buildozer_spec}")
buildozer_build: Path = config.project_dir / ".buildozer"
if buildozer_build.exists():
try:
shutil.rmtree(buildozer_build)
logging.info(f"[DEPLOY] {str(buildozer_build)} removed")
except PermissionError as e:
print(f"{type(e).__name__}: {e}")
logging.warning(f"[DEPLOY] Could not delete {buildozer_build}")
def create_config_file(main_file: Path, dry_run: bool = False):
"""
Creates a new pysidedeploy.spec
"""
config_file = main_file.parent / "pysidedeploy.spec"
logging.info(f"[DEPLOY] Creating config file {config_file}")
default_config_file = Path(__file__).parent / "default.spec"
# the config parser needs a reference to parse. So, in the case of --dry-run
# use the default.spec file.
if dry_run:
return default_config_file
shutil.copy(default_config_file, config_file)
return config_file
def finalize(config: DesktopConfig):
"""
Copy the executable into the final location
For Android deployment, this is done through buildozer
"""
exe_format = EXE_FORMAT
if config.mode == DesktopConfig.NuitkaMode.STANDALONE and sys.platform != "darwin":
exe_format = ".dist"
generated_exec_path = config.generated_files_path / (config.source_file.stem + exe_format)
if not generated_exec_path.exists():
logging.error(f"[DEPLOY] Executable not found at {generated_exec_path.absolute()}")
return
logging.info(f"[DEPLOY] executable generated at {generated_exec_path.absolute()}")
if not config.exe_dir:
logging.info("[DEPLOY] Not copying output executable because no output directory specified")
return
output_path = config.exe_dir / (config.title + exe_format)
if sys.platform == "darwin" or config.mode == DesktopConfig.NuitkaMode.STANDALONE:
# Copy the folder that contains the executable
logging.info(f"[DEPLOY] copying generated folder to {output_path.absolute()}")
shutil.copytree(generated_exec_path, output_path, dirs_exist_ok=True)
else:
# Copy a single file
logging.info(f"[DEPLOY] copying generated file to {output_path.absolute()}")
shutil.copy(generated_exec_path, output_path)
print(f"[DEPLOY] Executed file created in {output_path.absolute()}")

View File

@@ -0,0 +1,184 @@
# 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 __future__ import annotations
# 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 shlex
import sys
from pathlib import Path
from project_lib import DesignStudioProject
from . import MAJOR_VERSION, run_command, DEFAULT_IGNORE_DIRS, PLUGINS_TO_REMOVE
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
]
self.files_to_ignore = [".cpp.o", ".qsb"]
@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_windows_command(self, source_file: Path, command: list):
"""
Special case for Windows where the command length is limited to 8191 characters.
"""
# if the platform is windows and the command is more than 8191 characters, the command
# will fail with the error message "The command line is too long". To avoid this, we will
# we will move the source_file to the intermediate source file called deploy_main.py, and
# include the Nuitka options direcly in the main file as mentioned in
# https://nuitka.net/user-documentation/user-manual.html#nuitka-project-options
# convert command into a format recognized by Nuitka when written to the main file
# the first item is ignore because it is 'python -m nuitka'
nuitka_comment_options = []
for command_entry in command[4:]:
nuitka_comment_options.append(f"# nuitka-project: {command_entry}")
nuitka_comment_options_str = "\n".join(nuitka_comment_options)
nuitka_comment_options_str += "\n"
# read the content of the source file
new_source_content = (nuitka_comment_options_str
+ Path(source_file).read_text(encoding="utf-8"))
# create and write back the new source content to deploy_main.py
new_source_file = source_file.parent / "deploy_main.py"
new_source_file.write_text(new_source_content, encoding="utf-8")
return new_source_file
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) -> str:
qt_plugins = [plugin for plugin in qt_plugins if plugin not in self.qt_plugins_to_ignore]
extra_args = shlex.split(extra_args)
# 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:
# include all the subdirectories in the project directory as data directories
# This includes all the qml modules
all_relevant_subdirs = []
for subdir in source_file.parent.iterdir():
if subdir.is_dir() and subdir.name not in DEFAULT_IGNORE_DIRS:
extra_args.append(f"--include-data-dir={subdir}="
f"./{subdir.name}")
all_relevant_subdirs.append(subdir)
# find all the qml files that are not included via the data directories
extra_qml_files = [file for file in qml_files
if file.parent not in all_relevant_subdirs]
# This will generate options for each file using:
# --include-data-files=ABSOLUTE_PATH_TO_FILE=RELATIVE_PATH_TO ROOT
# for each file.
qml_args.extend(
[f"--include-data-files={qml_file.resolve()}="
f"./{qml_file.resolve().relative_to(source_file.resolve().parent)}"
for qml_file in extra_qml_files]
)
if qml_files or DesignStudioProject.is_ds_project(source_file):
# 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()
# remove the following plugins from the qt_plugins list as Nuitka only checks
# for plugins within PySide6/Qt/plugins folder, and the following plugins
# are not present in the PySide6/Qt/plugins folder
qt_plugins = [plugin for plugin in qt_plugins if plugin not in PLUGINS_TO_REMOVE]
qt_plugins_str = ",".join(qt_plugins)
command.append(f"--include-qt-plugins={qt_plugins_str}")
long_command = False
if sys.platform == "win32" and len(" ".join(str(cmd) for cmd in command)) > 7000:
logging.info("[DEPLOY] Nuitka command too long for Windows. "
"Copying the contents of main Python file to an intermediate "
"deploy_main.py file")
long_command = True
new_source_file = self._create_windows_command(source_file=source_file, command=command)
command = self.nuitka + [os.fspath(new_source_file)]
command_str, _ = run_command(command=command, dry_run=dry_run)
# if deploy_main.py exists, delete it after the command is run
if long_command:
os.remove(source_file.parent / "deploy_main.py")
return command_str

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

View File

@@ -0,0 +1,123 @@
# 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 __future__ import annotations
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))