Initial commit
This commit is contained in:
@@ -0,0 +1,68 @@
|
||||
# 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", "wayland-inputdevice-integration"]
|
||||
|
||||
|
||||
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
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
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