First upload, 18 controller version
This commit is contained in:
@@ -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
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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
|
||||
@@ -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
|
||||
@@ -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 =
|
||||
@@ -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)
|
||||
@@ -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()}")
|
||||
@@ -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.
Binary file not shown.
|
After Width: | Height: | Size: 47 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 8.0 KiB |
@@ -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))
|
||||
Reference in New Issue
Block a user