exe 依赖添加

This commit is contained in:
Neo
2026-01-27 23:19:54 +08:00
parent 8b1200383e
commit ba00654ac5
3443 changed files with 754994 additions and 51 deletions

View File

@@ -0,0 +1,218 @@
# 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
""" pyside6-deploy deployment tool
Deployment tool that uses Nuitka to deploy PySide6 applications to various desktop (Windows,
Linux, macOS) platforms.
How does it work?
Command: pyside6-deploy path/to/main_file
pyside6-deploy (incase main file is called main.py)
pyside6-deploy -c /path/to/config_file
Platforms supported: Linux, Windows, macOS
Module binary inclusion:
1. for non-QML cases, only required modules are included
2. for QML cases, all modules are included because of all QML plugins getting included
with nuitka
Config file:
On the first run of the tool, it creates a config file called pysidedeploy.spec which
controls the various characteristic of the deployment. Users can simply change the value
in this config file to achieve different properties ie. change the application name,
deployment platform etc.
Note: This file is used by both pyside6-deploy and pyside6-android-deploy
"""
import sys
import argparse
import logging
import traceback
from pathlib import Path
from textwrap import dedent
from deploy_lib import (MAJOR_VERSION, DesktopConfig, cleanup, config_option_exists,
finalize, create_config_file, PythonExecutable, Nuitka,
HELP_EXTRA_MODULES, HELP_EXTRA_IGNORE_DIRS)
TOOL_DESCRIPTION = dedent(f"""
This tool deploys PySide{MAJOR_VERSION} to desktop (Windows, Linux,
macOS) platforms. The following types of executables are produced as per
the platform:
Windows = .exe
macOS = .app
Linux = .bin
""")
HELP_MODE = dedent("""
The mode in which the application is deployed. The options are: onefile,
standalone. The default value is onefile.
This options translates to the mode Nuitka uses to create the executable.
macOS by default uses the --standalone option.
""")
def main(main_file: Path = None, name: str = None, config_file: Path = None, init: bool = False,
loglevel=logging.WARNING, dry_run: bool = False, keep_deployment_files: bool = False,
force: bool = False, extra_ignore_dirs: str = None, extra_modules_grouped: str = None,
mode: str = None) -> str | None:
"""
Entry point for pyside6-deploy command.
:return: If successful, the Nuitka command that was executed. None otherwise.
"""
logging.basicConfig(level=loglevel)
# In case pyside6-deploy is run from a completely different location than the project directory
if main_file and main_file.exists():
config_file = main_file.parent / "pysidedeploy.spec"
if config_file and not config_file.exists() and not main_file.exists():
raise RuntimeError(dedent("""
Directory does not contain main.py file.
Please specify the main Python entry point file or the pysidedeploy.spec config file.
Run "pyside6-deploy --help" to see info about CLI options.
pyside6-deploy exiting..."""))
logging.info("[DEPLOY] Start")
if extra_ignore_dirs:
extra_ignore_dirs = extra_ignore_dirs.split(",")
extra_modules = []
if extra_modules_grouped:
tmp_extra_modules = extra_modules_grouped.split(",")
for extra_module in tmp_extra_modules:
if extra_module.startswith("Qt"):
extra_modules.append(extra_module[2:])
else:
extra_modules.append(extra_module)
python = PythonExecutable(dry_run=dry_run, init=init, force=force)
config_file_exists = config_file and config_file.exists()
if config_file_exists:
logging.info(f"[DEPLOY] Using existing config file {config_file}")
else:
config_file = create_config_file(main_file=main_file, dry_run=dry_run)
config = DesktopConfig(config_file=config_file, source_file=main_file, python_exe=python.exe,
dry_run=dry_run, existing_config_file=config_file_exists,
extra_ignore_dirs=extra_ignore_dirs, mode=mode, name=name)
cleanup(config=config)
python.install_dependencies(config=config, packages="packages")
# required by Nuitka for pyenv Python
add_arg = " --static-libpython=no"
if python.is_pyenv_python() and add_arg not in config.extra_args:
config.extra_args += add_arg
config.modules += list(set(extra_modules).difference(set(config.modules)))
# Do not save the config changes if --dry-run is specified
if not dry_run:
config.update_config()
if config.qml_files:
logging.info("[DEPLOY] Included QML files: "
f"{[str(qml_file) for qml_file in config.qml_files]}")
if init:
# Config file created above. Exiting.
logging.info(f"[DEPLOY]: Config file {config.config_file} created")
return
# If modules contain QtSql and the platform is macOS, then pyside6-deploy will not work
# currently. The fix ideally will have to come from Nuitka.
# See PYSIDE-2835
# TODO: Remove this check once the issue is fixed in Nuitka
# Nuitka Issue: https://github.com/Nuitka/Nuitka/issues/3079
if "Sql" in config.modules and sys.platform == "darwin":
print("[DEPLOY] QtSql Application is not supported on macOS with pyside6-deploy")
return
command_str = None
try:
# Run the Nuitka command to create the executable
if not dry_run:
logging.info("[DEPLOY] Deploying application")
nuitka = Nuitka(nuitka=[python.exe, "-m", "nuitka"])
command_str = nuitka.create_executable(source_file=config.source_file,
extra_args=config.extra_args,
qml_files=config.qml_files,
qt_plugins=config.qt_plugins,
excluded_qml_plugins=config.excluded_qml_plugins,
icon=config.icon,
dry_run=dry_run,
permissions=config.permissions,
mode=config.mode)
if not dry_run:
logging.info("[DEPLOY] Successfully deployed application")
except Exception:
print(f"[DEPLOY] Exception occurred: {traceback.format_exc()}")
finally:
if config.generated_files_path:
if not dry_run:
finalize(config=config)
if not keep_deployment_files:
cleanup(config=config)
logging.info("[DEPLOY] End")
return command_str
if __name__ == "__main__":
parser = argparse.ArgumentParser(description=TOOL_DESCRIPTION)
parser.add_argument("-c", "--config-file", type=lambda p: Path(p).absolute(),
default=(Path.cwd() / "pysidedeploy.spec"),
help="Path to the .spec config file")
parser.add_argument(
type=lambda p: Path(p).absolute(),
help="Path to main python file", nargs="?", dest="main_file",
default=None if config_option_exists() else Path.cwd() / "main.py")
parser.add_argument(
"--init", action="store_true",
help="Create pysidedeploy.spec file, if it doesn't already exists")
parser.add_argument(
"-v", "--verbose", help="Run in verbose mode", action="store_const",
dest="loglevel", const=logging.INFO)
parser.add_argument("--dry-run", action="store_true", help="Show the commands to be run")
parser.add_argument(
"--keep-deployment-files", action="store_true",
help="Keep the generated deployment files generated")
parser.add_argument("-f", "--force", action="store_true", help="Force all input prompts")
parser.add_argument("--name", type=str, help="Application name")
parser.add_argument("--extra-ignore-dirs", type=str, help=HELP_EXTRA_IGNORE_DIRS)
parser.add_argument("--extra-modules", type=str, help=HELP_EXTRA_MODULES)
parser.add_argument("--mode", choices=["onefile", "standalone"], default="onefile",
help=HELP_MODE)
args = parser.parse_args()
main(args.main_file, args.name, args.config_file, args.init, args.loglevel, args.dry_run,
args.keep_deployment_files, args.force, args.extra_ignore_dirs, args.extra_modules,
args.mode)

View File

@@ -0,0 +1,67 @@
# Copyright (C) 2022 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR LGPL-3.0-only OR GPL-2.0-only OR GPL-3.0-only
from __future__ import annotations
import sys
from pathlib import Path
from textwrap import dedent
MAJOR_VERSION = 6
if sys.platform == "win32":
IMAGE_FORMAT = ".ico"
EXE_FORMAT = ".exe"
elif sys.platform == "darwin":
IMAGE_FORMAT = ".icns"
EXE_FORMAT = ".app"
else:
IMAGE_FORMAT = ".jpg"
EXE_FORMAT = ".bin"
DEFAULT_APP_ICON = str((Path(__file__).parent / f"pyside_icon{IMAGE_FORMAT}").resolve())
DEFAULT_IGNORE_DIRS = {"site-packages", "deployment", ".git", ".qtcreator", "build", "dist",
"tests", "doc", "docs", "examples", ".vscode", "__pycache__"}
IMPORT_WARNING_PYSIDE = (f"[DEPLOY] Found 'import PySide6' in file {0}"
". Use 'from PySide6 import <module>' or pass the module"
" needed using --extra-modules command line argument")
HELP_EXTRA_IGNORE_DIRS = dedent("""
Comma-separated directory names inside the project dir. These
directories will be skipped when searching for Python files
relevant to the project.
Example usage: --extra-ignore-dirs=doc,translations
""")
HELP_EXTRA_MODULES = dedent("""
Comma-separated list of Qt modules to be added to the application,
in case they are not found automatically.
This occurs when you have 'import PySide6' in your code instead
'from PySide6 import <module>'. The module name is specified
by either omitting the prefix of Qt or including it.
Example usage 1: --extra-modules=Network,Svg
Example usage 2: --extra-modules=QtNetwork,QtSvg
""")
# plugins to be removed from the --include-qt-plugins option because these plugins
# don't exist in site-package under PySide6/Qt/plugins
PLUGINS_TO_REMOVE = ["accessiblebridge", "platforms/darwin", "networkaccess", "scenegraph"]
def get_all_pyside_modules():
"""
Returns all the modules installed with PySide6
"""
import PySide6
# They all start with `Qt` as the prefix. Removing this prefix and getting the actual
# module name
return [module[2:] for module in PySide6.__all__]
from .commands import run_command, run_qmlimportscanner
from .dependency_util import find_pyside_modules, find_permission_categories, QtDependencyReader
from .nuitka_helper import Nuitka
from .config import BaseConfig, Config, DesktopConfig
from .python_helper import PythonExecutable
from .deploy_util import cleanup, finalize, create_config_file, config_option_exists

View File

@@ -0,0 +1,63 @@
# Copyright (C) 2022 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR LGPL-3.0-only OR GPL-2.0-only OR GPL-3.0-only
from __future__ import annotations
import json
import subprocess
import sys
from pathlib import Path
from functools import lru_cache
from . import DEFAULT_IGNORE_DIRS
"""
All utility functions for deployment
"""
def run_command(command, dry_run: bool, fetch_output: bool = False):
command_str = " ".join([str(cmd) for cmd in command])
output = None
is_windows = (sys.platform == "win32")
try:
if not dry_run:
if fetch_output:
output = subprocess.check_output(command, shell=is_windows)
else:
subprocess.check_call(command, shell=is_windows)
else:
print(command_str + "\n")
except FileNotFoundError as error:
raise FileNotFoundError(f"[DEPLOY] {error.filename} not found")
except subprocess.CalledProcessError as error:
raise RuntimeError(
f"[DEPLOY] Command {command_str} failed with error {error} and return_code"
f"{error.returncode}"
)
except Exception as error:
raise RuntimeError(f"[DEPLOY] Command {command_str} failed with error {error}")
return command_str, output
@lru_cache
def run_qmlimportscanner(project_dir: Path, dry_run: bool):
"""
Runs pyside6-qmlimportscanner to find all the imported qml modules in project_dir
"""
qml_modules = []
cmd = ["pyside6-qmlimportscanner", "-rootPath", str(project_dir)]
for ignore_dir in DEFAULT_IGNORE_DIRS:
cmd.extend(["-exclude", ignore_dir])
if dry_run:
run_command(command=cmd, dry_run=True)
# Run qmlimportscanner during dry_run as well to complete the command being run by nuitka
_, json_string = run_command(command=cmd, dry_run=False, fetch_output=True)
json_string = json_string.decode("utf-8")
json_array = json.loads(json_string)
qml_modules = [item['name'] for item in json_array if item['type'] == "module"]
return qml_modules

View File

@@ -0,0 +1,532 @@
# Copyright (C) 2022 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR LGPL-3.0-only OR GPL-2.0-only OR GPL-3.0-only
from __future__ import annotations
import sys
import configparser
import logging
import tempfile
import warnings
from configparser import ConfigParser
from pathlib import Path
from enum import Enum
from project_lib import ProjectData, DesignStudioProject, resolve_valid_project_file
from . import (DEFAULT_APP_ICON, DEFAULT_IGNORE_DIRS, find_pyside_modules,
find_permission_categories, QtDependencyReader, run_qmlimportscanner)
# Some QML plugins like QtCore are excluded from this list as they don't contribute much to
# executable size. Excluding them saves the extra processing of checking for them in files
EXCLUDED_QML_PLUGINS = {"QtQuick", "QtQuick3D", "QtCharts", "QtWebEngine", "QtTest", "QtSensors"}
PERMISSION_MAP = {"Bluetooth": "NSBluetoothAlwaysUsageDescription:BluetoothAccess",
"Camera": "NSCameraUsageDescription:CameraAccess",
"Microphone": "NSMicrophoneUsageDescription:MicrophoneAccess",
"Contacts": "NSContactsUsageDescription:ContactsAccess",
"Calendar": "NSCalendarsUsageDescription:CalendarAccess",
# for iOS NSLocationWhenInUseUsageDescription and
# NSLocationAlwaysAndWhenInUseUsageDescription are also required.
"Location": "NSLocationUsageDescription:LocationAccess",
}
class BaseConfig:
"""Wrapper class around any .spec file with function to read and set values for the .spec file
"""
def __init__(self, config_file: Path, comment_prefixes: str = "/",
existing_config_file: bool = False) -> None:
self.config_file = config_file
self.existing_config_file = existing_config_file
self.parser = ConfigParser(comment_prefixes=comment_prefixes, strict=False,
allow_no_value=True)
self.parser.read(self.config_file)
def update_config(self):
logging.info(f"[DEPLOY] Updating config file {self.config_file}")
# This section of code is done to preserve the formatting of the original deploy.spec
# file where there is blank line before the comments
with tempfile.NamedTemporaryFile('w+', delete=False) as temp_file:
self.parser.write(temp_file, space_around_delimiters=True)
temp_file_path = temp_file.name
# Read the temporary file and write back to the original file with blank lines before
# comments
with open(temp_file_path, 'r') as temp_file, open(self.config_file, 'w') as config_file:
previous_line = None
for line in temp_file:
if (line.lstrip().startswith('#') and previous_line is not None
and not previous_line.lstrip().startswith('#')):
config_file.write('\n')
config_file.write(line)
previous_line = line
# Clean up the temporary file
Path(temp_file_path).unlink()
def set_value(self, section: str, key: str, new_value: str, raise_warning: bool = True) -> None:
try:
current_value = self.get_value(section, key, ignore_fail=True)
if current_value != new_value:
self.parser.set(section, key, new_value)
except configparser.NoOptionError:
if not raise_warning:
return
logging.warning(f"[DEPLOY] Set key '{key}': Key does not exist in section '{section}'")
except configparser.NoSectionError:
if not raise_warning:
return
logging.warning(f"[DEPLOY] Section '{section}' does not exist")
def get_value(self, section: str, key: str, ignore_fail: bool = False) -> str | None:
try:
return self.parser.get(section, key)
except configparser.NoOptionError:
if ignore_fail:
return None
logging.warning(f"[DEPLOY] Get key '{key}': Key does not exist in section {section}")
except configparser.NoSectionError:
if ignore_fail:
return None
logging.warning(f"[DEPLOY] Section '{section}': does not exist")
class Config(BaseConfig):
"""
Wrapper class around pysidedeploy.spec file, whose options are used to control the executable
creation
"""
def __init__(self, config_file: Path, source_file: Path, python_exe: Path, dry_run: bool,
existing_config_file: bool = False, extra_ignore_dirs: list[str] = None,
name: str = None):
super().__init__(config_file=config_file, existing_config_file=existing_config_file)
self.extra_ignore_dirs = extra_ignore_dirs
self._dry_run = dry_run
self.qml_modules = set()
self.source_file = Path(
self.set_or_fetch(property_value=source_file, property_key="input_file")
).resolve()
self.python_path = Path(
self.set_or_fetch(
property_value=python_exe,
property_key="python_path",
property_group="python",
)
)
self.title = self.set_or_fetch(property_value=name, property_key="title")
config_icon = self.get_value("app", "icon")
if config_icon:
self._icon = str(Path(config_icon).resolve())
else:
self.icon = DEFAULT_APP_ICON
proj_dir = self.get_value("app", "project_dir")
if proj_dir:
self._project_dir = Path(proj_dir).resolve()
else:
self.project_dir = self._find_project_dir()
exe_directory = self.get_value("app", "exec_directory")
if exe_directory:
self._exe_dir = Path(exe_directory).absolute()
else:
self.exe_dir = self._find_exe_dir()
self._project_file = None
proj_file = self.get_value("app", "project_file")
if proj_file:
self._project_file = self.project_dir / proj_file
else:
proj_file = self._find_project_file()
if proj_file:
self.project_file = proj_file
self.project_data = None
if self.project_file and self.project_file.exists():
self.project_data = ProjectData(project_file=self.project_file)
self._qml_files = []
# Design Studio projects include the qml files using Qt resources
if source_file and not DesignStudioProject.is_ds_project(source_file):
config_qml_files = self.get_value("qt", "qml_files")
if config_qml_files and self.project_dir and self.existing_config_file:
self._qml_files = [Path(self.project_dir)
/ file for file in config_qml_files.split(",")]
else:
self.qml_files = self._find_qml_files()
self._excluded_qml_plugins = []
excl_qml_plugins = self.get_value("qt", "excluded_qml_plugins")
if excl_qml_plugins and self.existing_config_file:
self._excluded_qml_plugins = excl_qml_plugins.split(",")
else:
self.excluded_qml_plugins = self._find_excluded_qml_plugins()
self._generated_files_path = self.source_file.parent / "deployment"
self.modules = []
def set_or_fetch(self, property_value, property_key, property_group="app") -> str:
"""
If a new property value is provided, store it in the config file
Otherwise return the existing value in the config file.
Raise an exception if neither are available.
:param property_value: The value to set if provided.
:param property_key: The configuration key.
:param property_group: The configuration group (default is "app").
:return: The configuration value.
:raises RuntimeError: If no value is provided and no existing value is found.
"""
existing_value = self.get_value(property_group, property_key)
if property_value:
self.set_value(property_group, property_key, str(property_value))
return property_value
if existing_value:
return existing_value
raise RuntimeError(
f"[DEPLOY] No value for {property_key} specified in config file or as cli option"
)
@property
def dry_run(self) -> bool:
return self._dry_run
@property
def generated_files_path(self) -> Path:
return self._generated_files_path
@property
def qml_files(self) -> list[Path]:
return self._qml_files
@qml_files.setter
def qml_files(self, qml_files: list[Path]):
self._qml_files = qml_files
qml_files = [str(file.absolute().relative_to(self.project_dir.absolute()))
if file.absolute().is_relative_to(self.project_dir) else str(file.absolute())
for file in self.qml_files]
qml_files.sort()
self.set_value("qt", "qml_files", ",".join(qml_files))
@property
def project_dir(self) -> Path:
return self._project_dir
@project_dir.setter
def project_dir(self, project_dir: Path) -> None:
rel_path = (
project_dir.relative_to(self.config_file.parent)
if project_dir.is_relative_to(self.config_file.parent)
else project_dir
)
self._project_dir = project_dir
self.set_value("app", "project_dir", str(rel_path))
@property
def project_file(self) -> Path:
return self._project_file
@project_file.setter
def project_file(self, project_file: Path):
self._project_file = project_file
self.set_value("app", "project_file", str(project_file.relative_to(self.project_dir)))
@property
def title(self) -> str:
return self._title
@title.setter
def title(self, title: str):
self._title = title
@property
def icon(self) -> str:
return self._icon
@icon.setter
def icon(self, icon: str):
self._icon = icon
self.set_value("app", "icon", icon)
@property
def source_file(self) -> Path:
return self._source_file
@source_file.setter
def source_file(self, source_file: Path) -> None:
rel_path = (
source_file.relative_to(self.config_file.parent)
if source_file.is_relative_to(self.config_file.parent)
else source_file
)
self._source_file = source_file
self.set_value("app", "input_file", str(rel_path))
@property
def python_path(self) -> Path:
return self._python_path
@python_path.setter
def python_path(self, python_path: Path):
self._python_path = python_path
@property
def extra_args(self) -> str:
return self.get_value("nuitka", "extra_args")
@extra_args.setter
def extra_args(self, extra_args: str):
self.set_value("nuitka", "extra_args", extra_args)
@property
def excluded_qml_plugins(self) -> list[str]:
return self._excluded_qml_plugins
@excluded_qml_plugins.setter
def excluded_qml_plugins(self, excluded_qml_plugins: list[str]):
self._excluded_qml_plugins = excluded_qml_plugins
if excluded_qml_plugins: # check required for Android
excluded_qml_plugins.sort()
self.set_value("qt", "excluded_qml_plugins", ",".join(excluded_qml_plugins))
@property
def exe_dir(self) -> Path:
return self._exe_dir
@exe_dir.setter
def exe_dir(self, exe_dir: Path):
self._exe_dir = exe_dir
self.set_value("app", "exec_directory", str(exe_dir))
@property
def modules(self) -> list[str]:
return self._modules
@modules.setter
def modules(self, modules: list[str]):
self._modules = modules
modules.sort()
self.set_value("qt", "modules", ",".join(modules))
def _find_qml_files(self):
"""
Fetches all the qml_files in the folder and sets them if the
field qml_files is empty in the config_file
"""
if self.project_data:
qml_files = [(self.project_dir / str(qml_file)) for qml_file in
self.project_data.qml_files]
for sub_project_file in self.project_data.sub_projects_files:
qml_files.extend([self.project_dir / str(qml_file) for qml_file in
ProjectData(project_file=sub_project_file).qml_files])
else:
# Filter out files from DEFAULT_IGNORE_DIRS
qml_files = [
file for file in self.project_dir.glob("**/*.qml")
if all(part not in file.parts for part in DEFAULT_IGNORE_DIRS)
]
if len(qml_files) > 500:
warnings.warn(
"You seem to include a lot of QML files from "
f"{self.project_dir}. This can lead to errors in deployment."
)
return qml_files
def _find_project_dir(self) -> Path:
if DesignStudioProject.is_ds_project(self.source_file):
return DesignStudioProject(self.source_file).project_dir
# There is no other way to find the project_dir than assume it is the parent directory
# of source_file
return self.source_file.parent
def _find_project_file(self) -> Path | None:
if not self.source_file:
raise RuntimeError("[DEPLOY] Source file not set in config file")
if DesignStudioProject.is_ds_project(self.source_file):
pyproject_location = self.source_file.parent
else:
pyproject_location = self.project_dir
try:
return resolve_valid_project_file(pyproject_location)
except ValueError as e:
logging.warning(f"[DEPLOY] Unable to resolve a valid project file. Proceeding without a"
f" project file. Details:\n{e}.")
return None
def _find_excluded_qml_plugins(self) -> list[str] | None:
if not self.qml_files and not DesignStudioProject.is_ds_project(self.source_file):
return None
self.qml_modules = set(run_qmlimportscanner(project_dir=self.project_dir,
dry_run=self.dry_run))
excluded_qml_plugins = EXCLUDED_QML_PLUGINS.difference(self.qml_modules)
# sorting needed for dry_run testing
return sorted(excluded_qml_plugins)
def _find_exe_dir(self) -> Path:
if self.project_dir == Path.cwd():
return self.project_dir.relative_to(Path.cwd())
return self.project_dir
def _find_pysidemodules(self) -> list[str]:
modules = find_pyside_modules(project_dir=self.project_dir,
extra_ignore_dirs=self.extra_ignore_dirs,
project_data=self.project_data)
logging.info("The following PySide modules were found from the Python files of "
f"the project {modules}")
return modules
def _find_qtquick_modules(self) -> list[str]:
"""Identify if QtQuick is used in QML files and add them as dependency
"""
extra_modules = []
if not self.qml_modules and self.qml_files:
self.qml_modules = set(run_qmlimportscanner(project_dir=self.project_dir,
dry_run=self.dry_run))
if "QtQuick" in self.qml_modules:
extra_modules.append("Quick")
if "QtQuick.Controls" in self.qml_modules:
extra_modules.append("QuickControls2")
return extra_modules
class DesktopConfig(Config):
"""Wrapper class around pysidedeploy.spec, but specific to Desktop deployment
"""
class NuitkaMode(Enum):
ONEFILE = "onefile"
STANDALONE = "standalone"
def __init__(self, config_file: Path, source_file: Path, python_exe: Path, dry_run: bool,
existing_config_file: bool = False, extra_ignore_dirs: list[str] = None,
mode: str = "onefile", name: str = None):
super().__init__(config_file, source_file, python_exe, dry_run, existing_config_file,
extra_ignore_dirs, name=name)
self.dependency_reader = QtDependencyReader(dry_run=self.dry_run)
modules = self.get_value("qt", "modules")
if modules:
self._modules = modules.split(",")
else:
modules = self._find_pysidemodules()
modules += self._find_qtquick_modules()
modules += self._find_dependent_qt_modules(modules=modules)
# remove duplicates
self.modules = list(set(modules))
self._qt_plugins = []
if self.get_value("qt", "plugins"):
self._qt_plugins = self.get_value("qt", "plugins").split(",")
else:
self.qt_plugins = self.dependency_reader.find_plugin_dependencies(self.modules,
python_exe)
self._permissions = []
if sys.platform == "darwin":
nuitka_macos_permissions = self.get_value("nuitka", "macos.permissions")
if nuitka_macos_permissions:
self._permissions = nuitka_macos_permissions.split(",")
else:
self.permissions = self._find_permissions()
self._mode = self.NuitkaMode.ONEFILE
if self.get_value("nuitka", "mode") == self.NuitkaMode.STANDALONE.value:
self._mode = self.NuitkaMode.STANDALONE
elif mode == self.NuitkaMode.STANDALONE.value:
self.mode = self.NuitkaMode.STANDALONE
if DesignStudioProject.is_ds_project(self.source_file):
ds_project = DesignStudioProject(self.source_file)
if not ds_project.compiled_resources_available():
raise RuntimeError(f"[DEPLOY] Compiled resources file not found: "
f"{ds_project.compiled_resources_file.absolute()}. "
f"Build the project using 'pyside6-project build' or compile "
f"the resources manually using pyside6-rcc")
@property
def qt_plugins(self) -> list[str]:
return self._qt_plugins
@qt_plugins.setter
def qt_plugins(self, qt_plugins: list[str]):
self._qt_plugins = qt_plugins
qt_plugins.sort()
self.set_value("qt", "plugins", ",".join(qt_plugins))
@property
def permissions(self) -> list[str]:
return self._permissions
@permissions.setter
def permissions(self, permissions: list[str]):
self._permissions = permissions
permissions.sort()
self.set_value("nuitka", "macos.permissions", ",".join(permissions))
@property
def mode(self) -> NuitkaMode:
return self._mode
@mode.setter
def mode(self, mode: NuitkaMode):
self._mode = mode
self.set_value("nuitka", "mode", mode.value)
def _find_dependent_qt_modules(self, modules: list[str]) -> list[str]:
"""
Given pysidedeploy_config.modules, find all the other dependent Qt modules.
"""
all_modules = set(modules)
if not self.dependency_reader.lib_reader:
warnings.warn(f"[DEPLOY] Unable to find {self.dependency_reader.lib_reader_name}. This "
f"tool helps to find the Qt module dependencies of the application. "
f"Skipping checking for dependencies.", category=RuntimeWarning)
return []
for module_name in modules:
self.dependency_reader.find_dependencies(module=module_name, used_modules=all_modules)
return list(all_modules)
def _find_permissions(self) -> list[str]:
"""
Finds and sets the usage description string required for each permission requested by the
macOS application.
"""
permissions = []
perm_categories = find_permission_categories(project_dir=self.project_dir,
extra_ignore_dirs=self.extra_ignore_dirs,
project_data=self.project_data)
perm_categories_str = ",".join(perm_categories)
logging.info(f"[DEPLOY] Usage descriptions for the {perm_categories_str} will be added to "
"the Info.plist file of the macOS application bundle")
# Handling permissions
for perm_category in perm_categories:
if perm_category in PERMISSION_MAP:
permissions.append(PERMISSION_MAP[perm_category])
return permissions

View File

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

View File

@@ -0,0 +1,337 @@
# Copyright (C) 2024 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR LGPL-3.0-only OR GPL-2.0-only OR GPL-3.0-only
from __future__ import annotations
import ast
import re
import os
import site
import json
import warnings
import logging
import shutil
import sys
from pathlib import Path
from functools import lru_cache
from . import IMPORT_WARNING_PYSIDE, DEFAULT_IGNORE_DIRS, run_command
@lru_cache(maxsize=None)
def get_py_files(project_dir: Path, extra_ignore_dirs: tuple[Path] = None, project_data=None):
"""Finds and returns all the Python files in the project
"""
py_candidates = []
ignore_dirs = DEFAULT_IGNORE_DIRS.copy()
if project_data:
py_candidates = project_data.python_files
ui_candidates = project_data.ui_files
qrc_candidates = project_data.qrc_files
def add_uic_qrc_candidates(candidates, candidate_type):
possible_py_candidates = []
missing_files = []
for file in candidates:
py_file = file.parent / f"{candidate_type}_{file.stem}.py"
if py_file.exists():
possible_py_candidates.append(py_file)
else:
missing_files.append((str(file), str(py_file)))
if missing_files:
missing_details = "\n".join(
f"{candidate_type.upper()} file: {src} -> Missing Python file: {dst}"
for src, dst in missing_files
)
warnings.warn(
f"[DEPLOY] The following {candidate_type} files do not have corresponding "
f"Python files:\n {missing_details}",
category=RuntimeWarning
)
py_candidates.extend(possible_py_candidates)
if ui_candidates:
add_uic_qrc_candidates(ui_candidates, "ui")
if qrc_candidates:
add_uic_qrc_candidates(qrc_candidates, "rc")
return py_candidates
# incase there is not .pyproject file, search all python files in project_dir, except
# ignore_dirs
if extra_ignore_dirs:
ignore_dirs.update(extra_ignore_dirs)
# find relevant .py files
_walk = os.walk(project_dir)
for root, dirs, files in _walk:
dirs[:] = [d for d in dirs if d not in ignore_dirs and not d.startswith(".")]
for py_file in files:
if py_file.endswith(".py"):
py_candidates.append(Path(root) / py_file)
return py_candidates
@lru_cache(maxsize=None)
def get_ast(py_file: Path):
"""Given a Python file returns the abstract syntax tree
"""
contents = py_file.read_text(encoding="utf-8")
try:
tree = ast.parse(contents)
except SyntaxError:
print(f"[DEPLOY] Unable to parse {py_file}")
return tree
def find_permission_categories(project_dir: Path, extra_ignore_dirs: list[Path] = None,
project_data=None):
"""Given the project directory, finds all the permission categories required by the
project. eg: Camera, Bluetooth, Contacts etc.
Note: This function is only relevant for mac0S deployment.
"""
all_perm_categories = set()
mod_pattern = re.compile("Q(?P<mod_name>.*)Permission")
def pyside_permission_imports(py_file: Path):
perm_categories = []
try:
tree = get_ast(py_file)
for node in ast.walk(tree):
if isinstance(node, ast.ImportFrom):
main_mod_name = node.module
if main_mod_name == "PySide6.QtCore":
# considers 'from PySide6.QtCore import QtMicrophonePermission'
for imported_module in node.names:
full_mod_name = imported_module.name
match = mod_pattern.search(full_mod_name)
if match:
mod_name = match.group("mod_name")
perm_categories.append(mod_name)
continue
if isinstance(node, ast.Import):
for imported_module in node.names:
full_mod_name = imported_module.name
if full_mod_name == "PySide6":
logging.warning(IMPORT_WARNING_PYSIDE.format(str(py_file)))
except Exception as e:
raise RuntimeError(f"[DEPLOY] Finding permission categories failed on file "
f"{str(py_file)} with error {e}")
return set(perm_categories)
if extra_ignore_dirs is not None:
extra_ignore_dirs = tuple(extra_ignore_dirs)
py_candidates = get_py_files(project_dir, extra_ignore_dirs, project_data)
for py_candidate in py_candidates:
all_perm_categories = all_perm_categories.union(pyside_permission_imports(py_candidate))
if not all_perm_categories:
ValueError("[DEPLOY] No permission categories were found for macOS app bundle creation.")
return all_perm_categories
def find_pyside_modules(project_dir: Path, extra_ignore_dirs: list[Path] = None,
project_data=None):
"""
Searches all the python files in the project to find all the PySide modules used by
the application.
"""
all_modules = set()
mod_pattern = re.compile("PySide6.Qt(?P<mod_name>.*)")
@lru_cache
def pyside_module_imports(py_file: Path):
modules = []
try:
tree = get_ast(py_file)
for node in ast.walk(tree):
if isinstance(node, ast.ImportFrom):
main_mod_name = node.module
if main_mod_name and main_mod_name.startswith("PySide6"):
if main_mod_name == "PySide6":
# considers 'from PySide6 import QtCore'
for imported_module in node.names:
full_mod_name = imported_module.name
if full_mod_name.startswith("Qt"):
modules.append(full_mod_name[2:])
continue
# considers 'from PySide6.QtCore import Qt'
match = mod_pattern.search(main_mod_name)
if match:
mod_name = match.group("mod_name")
modules.append(mod_name)
else:
logging.warning((
f"[DEPLOY] Unable to find module name from {ast.dump(node)}"))
if isinstance(node, ast.Import):
for imported_module in node.names:
full_mod_name = imported_module.name
if full_mod_name == "PySide6":
logging.warning(IMPORT_WARNING_PYSIDE.format(str(py_file)))
except Exception as e:
raise RuntimeError(f"[DEPLOY] Finding module import failed on file {str(py_file)} with "
f"error {e}")
return set(modules)
if extra_ignore_dirs is not None:
extra_ignore_dirs = tuple(extra_ignore_dirs)
py_candidates = get_py_files(project_dir, extra_ignore_dirs, project_data)
for py_candidate in py_candidates:
all_modules = all_modules.union(pyside_module_imports(py_candidate))
if not all_modules:
ValueError("[DEPLOY] No PySide6 modules were found")
return list(all_modules)
class QtDependencyReader:
def __init__(self, dry_run: bool = False) -> None:
self.dry_run = dry_run
self.lib_reader_name = None
self.qt_module_path_pattern = None
self.lib_pattern = None
self.command = None
self.qt_libs_dir = None
if sys.platform == "linux":
self.lib_reader_name = "readelf"
self.qt_module_path_pattern = "libQt6{module}.so.6"
self.lib_pattern = re.compile("libQt6(?P<mod_name>.*).so.6")
self.command_args = "-d"
elif sys.platform == "darwin":
self.lib_reader_name = "dyld_info"
self.qt_module_path_pattern = "Qt{module}.framework/Versions/A/Qt{module}"
self.lib_pattern = re.compile("@rpath/Qt(?P<mod_name>.*).framework/Versions/A/")
self.command_args = "-dependents"
elif sys.platform == "win32":
self.lib_reader_name = "dumpbin"
self.qt_module_path_pattern = "Qt6{module}.dll"
self.lib_pattern = re.compile("Qt6(?P<mod_name>.*).dll")
self.command_args = "/dependents"
else:
print(f"[DEPLOY] Deployment on unsupported platfrom {sys.platform}")
sys.exit(1)
self.pyside_install_dir = None
self.qt_libs_dir = self.get_qt_libs_dir()
self._lib_reader = shutil.which(self.lib_reader_name)
def get_qt_libs_dir(self):
"""
Finds the path to the Qt libs directory inside PySide6 package installation
"""
# PYSIDE-2785 consider dist-packages for Debian based systems
for possible_site_package in site.getsitepackages():
if possible_site_package.endswith(("site-packages", "dist-packages")):
self.pyside_install_dir = Path(possible_site_package) / "PySide6"
if self.pyside_install_dir.exists():
break
if not self.pyside_install_dir:
print("Unable to find where PySide6 is installed. Exiting ...")
sys.exit(-1)
if sys.platform == "win32":
return self.pyside_install_dir
return self.pyside_install_dir / "Qt" / "lib" # for linux and macOS
@property
def lib_reader(self):
return self._lib_reader
def find_dependencies(self, module: str, used_modules: set[str] = None):
"""
Given a Qt module, find all the other Qt modules it is dependent on and add it to the
'used_modules' set
"""
qt_module_path = self.qt_libs_dir / self.qt_module_path_pattern.format(module=module)
if not qt_module_path.exists():
warnings.warn(f"[DEPLOY] {qt_module_path.name} not found in {str(qt_module_path)}."
"Skipping finding its dependencies.", category=RuntimeWarning)
return
lib_pattern = re.compile(self.lib_pattern)
command = [self.lib_reader, self.command_args, str(qt_module_path)]
# print the command if dry_run is True.
# Normally run_command is going to print the command in dry_run mode. But, this is a
# special case where we need to print the command as well as to run it.
if self.dry_run:
command_str = " ".join(command)
print(command_str + "\n")
# We need to run this even for dry run, to see the full Nuitka command being executed
_, output = run_command(command=command, dry_run=False, fetch_output=True)
dependent_modules = set()
for line in output.splitlines():
line = line.decode("utf-8").lstrip()
if sys.platform == "darwin":
if line.endswith(f"Qt{module} [arm64]:"):
# macOS Qt frameworks bundles have both x86_64 and arm64 architectures
# We only need to consider one as the dependencies are redundant
break
elif line.endswith(f"Qt{module} [X86_64]:"):
# this line needs to be skipped because it matches with the pattern
# and is related to the module itself, not the dependencies of the module
continue
elif sys.platform == "win32" and line.startswith("Summary"):
# the dependencies would be found before the `Summary` line
break
match = lib_pattern.search(line)
if match:
dep_module = match.group("mod_name")
dependent_modules.add(dep_module)
if dep_module not in used_modules:
used_modules.add(dep_module)
self.find_dependencies(module=dep_module, used_modules=used_modules)
if dependent_modules:
logging.info(f"[DEPLOY] Following dependencies found for {module}: {dependent_modules}")
else:
logging.info(f"[DEPLOY] No Qt dependencies found for {module}")
def find_plugin_dependencies(self, used_modules: list[str], python_exe: Path) -> list[str]:
"""
Given the modules used by the application, returns all the required plugins
"""
plugins = set()
pyside_wheels = ["PySide6_Essentials", "PySide6_Addons"]
# TODO from 3.12 use list(dist.name for dist in importlib.metadata.distributions())
_, installed_packages = run_command(command=[str(python_exe), "-m", "pip", "freeze"],
dry_run=False, fetch_output=True)
installed_packages = [p.decode().split('==')[0] for p in installed_packages.split()]
for pyside_wheel in pyside_wheels:
if pyside_wheel not in installed_packages:
# the wheel is not installed and hence no plugins are checked for its modules
logging.warning((f"[DEPLOY] The package {pyside_wheel} is not installed. "))
continue
pyside_mod_plugin_json_name = f"{pyside_wheel}.json"
pyside_mod_plugin_json_file = self.pyside_install_dir / pyside_mod_plugin_json_name
if not pyside_mod_plugin_json_file.exists():
warnings.warn(f"[DEPLOY] Unable to find {pyside_mod_plugin_json_file}.",
category=RuntimeWarning)
continue
# convert the json to dict
pyside_mod_dict = {}
with open(pyside_mod_plugin_json_file) as pyside_json:
pyside_mod_dict = json.load(pyside_json)
# find all the plugins in the modules
for module in used_modules:
plugins.update(pyside_mod_dict.get(module, []))
return list(plugins)

View File

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

View File

@@ -0,0 +1,184 @@
# Copyright (C) 2022 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR LGPL-3.0-only OR GPL-2.0-only OR GPL-3.0-only
from __future__ import annotations
# enables to use typehints for classes that has not been defined yet or imported
# used for resolving circular imports
from __future__ import annotations
import logging
import os
import shlex
import sys
from pathlib import Path
from project_lib import DesignStudioProject
from . import MAJOR_VERSION, run_command, DEFAULT_IGNORE_DIRS, PLUGINS_TO_REMOVE
from .config import DesktopConfig
class Nuitka:
"""
Wrapper class around the nuitka executable, enabling its usage through python code
"""
def __init__(self, nuitka):
self.nuitka = nuitka
# plugins to ignore. The sensible plugins are include by default by Nuitka for PySide6
# application deployment
self.qt_plugins_to_ignore = ["imageformats", # being Nuitka `sensible`` plugins
"iconengines",
"mediaservice",
"printsupport",
"platforms",
"platformthemes",
"styles",
"wayland-shell-integration",
"wayland-decoration-client",
"wayland-graphics-integration-client",
"egldeviceintegrations",
"xcbglintegrations",
"tls", # end Nuitka `sensible` plugins
"generic" # plugins that error with Nuitka
]
self.files_to_ignore = [".cpp.o", ".qsb"]
@staticmethod
def icon_option():
if sys.platform == "linux":
return "--linux-icon"
elif sys.platform == "win32":
return "--windows-icon-from-ico"
else:
return "--macos-app-icon"
def _create_windows_command(self, source_file: Path, command: list):
"""
Special case for Windows where the command length is limited to 8191 characters.
"""
# if the platform is windows and the command is more than 8191 characters, the command
# will fail with the error message "The command line is too long". To avoid this, we will
# we will move the source_file to the intermediate source file called deploy_main.py, and
# include the Nuitka options direcly in the main file as mentioned in
# https://nuitka.net/user-documentation/user-manual.html#nuitka-project-options
# convert command into a format recognized by Nuitka when written to the main file
# the first item is ignore because it is 'python -m nuitka'
nuitka_comment_options = []
for command_entry in command[4:]:
nuitka_comment_options.append(f"# nuitka-project: {command_entry}")
nuitka_comment_options_str = "\n".join(nuitka_comment_options)
nuitka_comment_options_str += "\n"
# read the content of the source file
new_source_content = (nuitka_comment_options_str
+ Path(source_file).read_text(encoding="utf-8"))
# create and write back the new source content to deploy_main.py
new_source_file = source_file.parent / "deploy_main.py"
new_source_file.write_text(new_source_content, encoding="utf-8")
return new_source_file
def create_executable(self, source_file: Path, extra_args: str, qml_files: list[Path],
qt_plugins: list[str], excluded_qml_plugins: list[str], icon: str,
dry_run: bool, permissions: list[str],
mode: DesktopConfig.NuitkaMode) -> str:
qt_plugins = [plugin for plugin in qt_plugins if plugin not in self.qt_plugins_to_ignore]
extra_args = shlex.split(extra_args)
# macOS uses the --standalone option by default to create an app bundle
if sys.platform == "darwin":
# create an app bundle
extra_args.extend(["--standalone", "--macos-create-app-bundle"])
permission_pattern = "--macos-app-protected-resource={permission}"
for permission in permissions:
extra_args.append(permission_pattern.format(permission=permission))
else:
extra_args.append(f"--{mode.value}")
qml_args = []
if qml_files:
# include all the subdirectories in the project directory as data directories
# This includes all the qml modules
all_relevant_subdirs = []
for subdir in source_file.parent.iterdir():
if subdir.is_dir() and subdir.name not in DEFAULT_IGNORE_DIRS:
extra_args.append(f"--include-data-dir={subdir}="
f"./{subdir.name}")
all_relevant_subdirs.append(subdir)
# find all the qml files that are not included via the data directories
extra_qml_files = [file for file in qml_files
if file.parent not in all_relevant_subdirs]
# This will generate options for each file using:
# --include-data-files=ABSOLUTE_PATH_TO_FILE=RELATIVE_PATH_TO ROOT
# for each file.
qml_args.extend(
[f"--include-data-files={qml_file.resolve()}="
f"./{qml_file.resolve().relative_to(source_file.resolve().parent)}"
for qml_file in extra_qml_files]
)
if qml_files or DesignStudioProject.is_ds_project(source_file):
# add qml plugin. The `qml`` plugin name is not present in the module json files shipped
# with Qt and hence not in `qt_plugins``. However, Nuitka uses the 'qml' plugin name to
# include the necessary qml plugins. There we have to add it explicitly for a qml
# application
qt_plugins.append("qml")
if excluded_qml_plugins:
prefix = "lib" if sys.platform != "win32" else ""
for plugin in excluded_qml_plugins:
dll_name = plugin.replace("Qt", f"Qt{MAJOR_VERSION}")
qml_args.append(f"--noinclude-dlls={prefix}{dll_name}*")
# Exclude .qen json files from QtQuickEffectMaker
# These files are not relevant for PySide6 applications
qml_args.append("--noinclude-dlls=*/qml/QtQuickEffectMaker/*")
# Exclude files that cannot be processed by Nuitka
for file in self.files_to_ignore:
extra_args.append(f"--noinclude-dlls=*{file}")
output_dir = source_file.parent / "deployment"
if not dry_run:
output_dir.mkdir(parents=True, exist_ok=True)
logging.info("[DEPLOY] Running Nuitka")
command = self.nuitka + [
os.fspath(source_file),
"--follow-imports",
"--enable-plugin=pyside6",
f"--output-dir={output_dir}",
]
command.extend(extra_args + qml_args)
command.append(f"{self.__class__.icon_option()}={icon}")
if qt_plugins:
# sort qt_plugins so that the result is definitive when testing
qt_plugins.sort()
# remove the following plugins from the qt_plugins list as Nuitka only checks
# for plugins within PySide6/Qt/plugins folder, and the following plugins
# are not present in the PySide6/Qt/plugins folder
qt_plugins = [plugin for plugin in qt_plugins if plugin not in PLUGINS_TO_REMOVE]
qt_plugins_str = ",".join(qt_plugins)
command.append(f"--include-qt-plugins={qt_plugins_str}")
long_command = False
if sys.platform == "win32" and len(" ".join(str(cmd) for cmd in command)) > 7000:
logging.info("[DEPLOY] Nuitka command too long for Windows. "
"Copying the contents of main Python file to an intermediate "
"deploy_main.py file")
long_command = True
new_source_file = self._create_windows_command(source_file=source_file, command=command)
command = self.nuitka + [os.fspath(new_source_file)]
command_str, _ = run_command(command=command, dry_run=dry_run)
# if deploy_main.py exists, delete it after the command is run
if long_command:
os.remove(source_file.parent / "deploy_main.py")
return command_str

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

View File

@@ -0,0 +1,123 @@
# Copyright (C) 2022 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR LGPL-3.0-only OR GPL-2.0-only OR GPL-3.0-only
from __future__ import annotations
import logging
import os
import sys
from importlib import util
from importlib.metadata import version
from pathlib import Path
from . import Config, run_command
class PythonExecutable:
"""
Wrapper class around Python executable
"""
def __init__(self, python_path: Path = None, dry_run: bool = False, init: bool = False,
force: bool = False):
self.dry_run = dry_run
self.init = init
if not python_path:
response = "yes"
# checking if inside virtual environment
if not self.is_venv() and not force and not self.dry_run and not self.init:
response = input(("You are not using a virtual environment. pyside6-deploy needs "
"to install a few Python packages for deployment to work "
"seamlessly. \n Proceed? [Y/n]"))
if response.lower() in ["no", "n"]:
print("[DEPLOY] Exiting ...")
sys.exit(0)
self.exe = Path(sys.executable)
else:
self.exe = python_path
logging.info(f"[DEPLOY] Using Python at {str(self.exe)}")
@property
def exe(self):
return Path(self._exe)
@exe.setter
def exe(self, exe):
self._exe = exe
@staticmethod
def is_venv():
venv = os.environ.get("VIRTUAL_ENV")
return True if venv else False
def is_pyenv_python(self):
pyenv_root = os.environ.get("PYENV_ROOT")
if pyenv_root:
resolved_exe = self.exe.resolve()
if str(resolved_exe).startswith(pyenv_root):
return True
return False
def install(self, packages: list = None):
_, installed_packages = run_command(command=[str(self.exe), "-m", "pip", "freeze"],
dry_run=False, fetch_output=True)
installed_packages = [p.decode().split('==')[0] for p in installed_packages.split()]
for package in packages:
package_info = package.split('==')
package_components_len = len(package_info)
package_name, package_version = None, None
if package_components_len == 1:
package_name = package_info[0]
elif package_components_len == 2:
package_name = package_info[0]
package_version = package_info[1]
else:
raise ValueError(f"{package} should be of the format 'package_name'=='version'")
if (package_name not in installed_packages) and (not self.is_installed(package_name)):
logging.info(f"[DEPLOY] Installing package: {package}")
run_command(
command=[self.exe, "-m", "pip", "install", package],
dry_run=self.dry_run,
)
elif package_version:
installed_version = version(package_name)
if package_version != installed_version:
logging.info(f"[DEPLOY] Installing package: {package_name}"
f"version: {package_version}")
run_command(
command=[self.exe, "-m", "pip", "install", "--force", package],
dry_run=self.dry_run,
)
else:
logging.info(f"[DEPLOY] package: {package_name}=={package_version}"
" already installed")
else:
logging.info(f"[DEPLOY] package: {package_name} already installed")
def is_installed(self, package):
return bool(util.find_spec(package))
def install_dependencies(self, config: Config, packages: str, is_android: bool = False):
"""
Installs the python package dependencies for the target deployment platform
"""
packages = config.get_value("python", packages).split(",")
if not self.init:
# install packages needed for deployment
logging.info("[DEPLOY] Installing dependencies")
self.install(packages=packages)
# nuitka requires patchelf to make patchelf rpath changes for some Qt files
if sys.platform.startswith("linux") and not is_android:
self.install(packages=["patchelf"])
elif is_android:
# install only buildozer
logging.info("[DEPLOY] Installing buildozer")
buildozer_package_with_version = ([package for package in packages
if package.startswith("buildozer")])
self.install(packages=list(buildozer_package_with_version))

View File

@@ -0,0 +1,461 @@
# 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 ast
import json
import os
import sys
import tokenize
from argparse import ArgumentParser, RawTextHelpFormatter
from pathlib import Path
from typing import Union
DESCRIPTION = """Parses Python source code to create QObject metatype
information in JSON format for qmltyperegistrar."""
REVISION = 68
CPP_TYPE_MAPPING = {"str": "QString"}
QML_IMPORT_NAME = "QML_IMPORT_NAME"
QML_IMPORT_MAJOR_VERSION = "QML_IMPORT_MAJOR_VERSION"
QML_IMPORT_MINOR_VERSION = "QML_IMPORT_MINOR_VERSION"
QT_MODULES = "QT_MODULES"
ITEM_MODELS = ["QAbstractListModel", "QAbstractProxyModel",
"QAbstractTableModel", "QConcatenateTablesProxyModel",
"QFileSystemModel", "QIdentityProxyModel", "QPdfBookmarkModel",
"QPdfSearchModel", "QSortFilterProxyModel", "QSqlQueryModel",
"QStandardItemModel", "QStringListModel", "QTransposeProxyModel",
"QWebEngineHistoryModel"]
QOBJECT_DERIVED = ["QObject", "QQuickItem", "QQuickPaintedItem"] + ITEM_MODELS
# Python 3.9 does not support this syntax, yet
# AstDecorator = ast.Name | ast.Call
# AstPySideTypeSpec = ast.Name | ast.Constant
AstDecorator = Union[ast.Name, ast.Call]
AstPySideTypeSpec = Union[ast.Name, ast.Constant]
ClassList = list[dict]
# PropertyEntry = dict[str, str | int | bool]
PropertyEntry = dict[str, Union[str, int, bool]]
Argument = dict[str, str]
Arguments = list[Argument]
# Signal = dict[str, str | Arguments]
# Slot = dict[str, str | Arguments]
Signal = dict[str, Union[str, Arguments]]
Slot = dict[str, Union[str, Arguments]]
def _decorator(name: str, value: str) -> dict[str, str]:
"""Create a QML decorator JSON entry"""
return {"name": name, "value": value}
def _attribute(node: ast.Attribute) -> tuple[str, str]:
"""Split an attribute."""
return node.value.id, node.attr
def _name(node: ast.Name | ast.Attribute | ast.Constant) -> str:
"""Return the name of something that is either an attribute or a name,
such as base classes or call.func"""
if isinstance(node, ast.Constant):
return str(node.value)
if isinstance(node, ast.Attribute):
qualifier, name = _attribute(node)
return f"{qualifier}.{node.attr}"
return node.id
def _func_name(node: ast.Call) -> str:
return _name(node.func)
def _python_to_cpp_type(type: str) -> str:
"""Python to C++ type"""
c = CPP_TYPE_MAPPING.get(type)
return c if c else type
def _parse_property_kwargs(keywords: list[ast.keyword], prop: PropertyEntry):
"""Parse keyword arguments of @Property"""
for k in keywords:
if k.arg == "notify":
prop["notify"] = _name(k.value)
def _parse_assignment(node: ast.Assign) -> tuple[str | None, ast.AST | None]:
"""Parse an assignment and return a tuple of name, value."""
if len(node.targets) == 1 and isinstance(node.targets[0], ast.Name):
var_name = node.targets[0].id
return (var_name, node.value)
return (None, None)
def _parse_pyside_type(type_spec: AstPySideTypeSpec) -> str:
"""Parse type specification of a Slot/Property decorator. Usually a type,
but can also be a string constant with a C++ type name."""
if isinstance(type_spec, ast.Constant):
return type_spec.value
return _python_to_cpp_type(_name(type_spec))
def _parse_call_args(call: ast.Call):
"""Parse arguments of a Signal call/Slot decorator (type list)."""
result: Arguments = []
for n, arg in enumerate(call.args):
par_name = f"a{n + 1}"
par_type = _parse_pyside_type(arg)
result.append({"name": par_name, "type": par_type})
return result
def _parse_slot(func_name: str, call: ast.Call) -> Slot:
"""Parse a 'Slot' decorator."""
return_type = "void"
for kwarg in call.keywords:
if kwarg.arg == "result":
return_type = _python_to_cpp_type(_name(kwarg.value))
break
return {"access": "public", "name": func_name,
"arguments": _parse_call_args(call),
"returnType": return_type}
class VisitorContext:
"""Stores a list of QObject-derived classes encountered in order to find
out which classes inherit QObject."""
def __init__(self):
self.qobject_derived = QOBJECT_DERIVED
class MetaObjectDumpVisitor(ast.NodeVisitor):
"""AST visitor for parsing sources and creating the data structure for
JSON."""
def __init__(self, context: VisitorContext):
super().__init__()
self._context = context
self._json_class_list: ClassList = []
# Property by name, which will be turned into the JSON List later
self._properties: list[PropertyEntry] = []
self._signals: list[Signal] = []
self._within_class: bool = False
self._qt_modules: set[str] = set()
self._qml_import_name = ""
self._qml_import_major_version = 0
self._qml_import_minor_version = 0
def json_class_list(self) -> ClassList:
return self._json_class_list
def qml_import_name(self) -> str:
return self._qml_import_name
def qml_import_version(self) -> tuple[int, int]:
return (self._qml_import_major_version, self._qml_import_minor_version)
def qt_modules(self):
return sorted(self._qt_modules)
@staticmethod
def create_ast(filename: Path) -> ast.Module:
"""Create an Abstract Syntax Tree on which a visitor can be run"""
node = None
with tokenize.open(filename) as file:
node = ast.parse(file.read(), mode="exec")
return node
def visit_Assign(self, node: ast.Assign):
"""Parse the global constants for QML-relevant values"""
var_name, value_node = _parse_assignment(node)
if not var_name or not isinstance(value_node, ast.Constant):
return
value = value_node.value
if var_name == QML_IMPORT_NAME:
self._qml_import_name = value
elif var_name == QML_IMPORT_MAJOR_VERSION:
self._qml_import_major_version = value
elif var_name == QML_IMPORT_MINOR_VERSION:
self._qml_import_minor_version = value
def visit_ClassDef(self, node: ast.Module):
"""Visit a class definition"""
self._properties = []
self._signals = []
self._slots = []
self._within_class = True
qualified_name = node.name
last_dot = qualified_name.rfind('.')
name = (qualified_name[last_dot + 1:] if last_dot != -1
else qualified_name)
data = {"className": name,
"qualifiedClassName": qualified_name}
q_object = False
bases = []
for b in node.bases:
# PYSIDE-2202: catch weird constructs like "class C(type(Base)):"
if isinstance(b, ast.Name):
base_name = _name(b)
if base_name in self._context.qobject_derived:
q_object = True
self._context.qobject_derived.append(name)
base_dict = {"access": "public", "name": base_name}
bases.append(base_dict)
data["object"] = q_object
if bases:
data["superClasses"] = bases
class_decorators: list[dict] = []
for d in node.decorator_list:
self._parse_class_decorator(d, class_decorators)
if class_decorators:
data["classInfos"] = class_decorators
for b in node.body:
if isinstance(b, ast.Assign):
self._parse_class_variable(b)
else:
self.visit(b)
if self._properties:
data["properties"] = self._properties
if self._signals:
data["signals"] = self._signals
if self._slots:
data["slots"] = self._slots
self._json_class_list.append(data)
self._within_class = False
def visit_FunctionDef(self, node):
if self._within_class:
for d in node.decorator_list:
self._parse_function_decorator(node.name, d)
def _parse_class_decorator(self, node: AstDecorator,
class_decorators: list[dict]):
"""Parse ClassInfo decorators."""
if isinstance(node, ast.Call):
name = _func_name(node)
if name == "QmlUncreatable":
class_decorators.append(_decorator("QML.Creatable", "false"))
if node.args:
reason = node.args[0].value
if isinstance(reason, str):
d = _decorator("QML.UncreatableReason", reason)
class_decorators.append(d)
elif name == "QmlAttached" and len(node.args) == 1:
d = _decorator("QML.Attached", node.args[0].id)
class_decorators.append(d)
elif name == "QmlExtended" and len(node.args) == 1:
d = _decorator("QML.Extended", node.args[0].id)
class_decorators.append(d)
elif name == "ClassInfo" and node.keywords:
kw = node.keywords[0]
class_decorators.append(_decorator(kw.arg, kw.value.value))
elif name == "QmlForeign" and len(node.args) == 1:
d = _decorator("QML.Foreign", node.args[0].id)
class_decorators.append(d)
elif name == "QmlNamedElement" and node.args:
name = node.args[0].value
class_decorators.append(_decorator("QML.Element", name))
elif name.startswith('Q'):
print('Unknown decorator with parameters:', name,
file=sys.stderr)
return
if isinstance(node, ast.Name):
name = node.id
if name == "QmlElement":
class_decorators.append(_decorator("QML.Element", "auto"))
elif name == "QmlSingleton":
class_decorators.append(_decorator("QML.Singleton", "true"))
elif name == "QmlAnonymous":
class_decorators.append(_decorator("QML.Element", "anonymous"))
elif name.startswith('Q'):
print('Unknown decorator:', name, file=sys.stderr)
return
def _index_of_property(self, name: str) -> int:
"""Search a property by name"""
for i in range(len(self._properties)):
if self._properties[i]["name"] == name:
return i
return -1
def _create_property_entry(self, name: str, type: str,
getter: str | None = None) -> PropertyEntry:
"""Create a property JSON entry."""
result: PropertyEntry = {"name": name, "type": type,
"index": len(self._properties)}
if getter:
result["read"] = getter
return result
def _parse_function_decorator(self, func_name: str, node: AstDecorator):
"""Parse function decorators."""
if isinstance(node, ast.Attribute):
name = node.value.id
value = node.attr
if value == "setter": # Property setter
idx = self._index_of_property(name)
if idx != -1:
self._properties[idx]["write"] = func_name
return
if isinstance(node, ast.Call):
name = _name(node.func)
if name == "Property": # Property getter
if node.args: # 1st is type/type string
type = _parse_pyside_type(node.args[0])
prop = self._create_property_entry(func_name, type,
func_name)
_parse_property_kwargs(node.keywords, prop)
self._properties.append(prop)
elif name == "Slot":
self._slots.append(_parse_slot(func_name, node))
else:
print('Unknown decorator with parameters:', name,
file=sys.stderr)
def _parse_class_variable(self, node: ast.Assign):
"""Parse a class variable assignment (Property, Signal, etc.)"""
(var_name, call) = _parse_assignment(node)
if not var_name or not isinstance(node.value, ast.Call):
return
func_name = _func_name(call)
if func_name == "Signal" or func_name == "QtCore.Signal":
signal: Signal = {"access": "public", "name": var_name,
"arguments": _parse_call_args(call),
"returnType": "void"}
self._signals.append(signal)
elif func_name == "Property" or func_name == "QtCore.Property":
type = _python_to_cpp_type(call.args[0].id)
prop = self._create_property_entry(var_name, type, call.args[1].id)
if len(call.args) > 2:
prop["write"] = call.args[2].id
_parse_property_kwargs(call.keywords, prop)
self._properties.append(prop)
elif func_name == "ListProperty" or func_name == "QtCore.ListProperty":
type = _python_to_cpp_type(call.args[0].id)
type = f"QQmlListProperty<{type}>"
prop = self._create_property_entry(var_name, type)
self._properties.append(prop)
def visit_Import(self, node):
for n in node.names: # "import PySide6.QtWidgets"
self._handle_import(n.name)
def visit_ImportFrom(self, node):
if "." in node.module: # "from PySide6.QtWidgets import QWidget"
self._handle_import(node.module)
elif node.module == "PySide6": # "from PySide6 import QtWidgets"
for n in node.names:
if n.name.startswith("Qt"):
self._qt_modules.add(n.name)
def _handle_import(self, mod: str):
if mod.startswith("PySide6."):
self._qt_modules.add(mod[8:])
def create_arg_parser(desc: str) -> ArgumentParser:
parser = ArgumentParser(description=desc,
formatter_class=RawTextHelpFormatter)
parser.add_argument('--compact', '-c', action='store_true',
help='Use compact format')
parser.add_argument('--suppress-file', '-s', action='store_true',
help='Suppress inputFile entry (for testing)')
parser.add_argument('--quiet', '-q', action='store_true',
help='Suppress warnings')
parser.add_argument('files', type=str, nargs="+",
help='Python source file')
parser.add_argument('--out-file', '-o', type=str,
help='Write output to file rather than stdout')
return parser
def parse_file(file: Path, context: VisitorContext,
suppress_file: bool = False) -> dict | None:
"""Parse a file and return its json data"""
ast_tree = MetaObjectDumpVisitor.create_ast(file)
visitor = MetaObjectDumpVisitor(context)
visitor.visit(ast_tree)
class_list = visitor.json_class_list()
if not class_list:
return None
result = {"classes": class_list,
"outputRevision": REVISION}
# Non-standard QML-related values for pyside6-build usage
if visitor.qml_import_name():
result[QML_IMPORT_NAME] = visitor.qml_import_name()
qml_import_version = visitor.qml_import_version()
if qml_import_version[0]:
result[QML_IMPORT_MAJOR_VERSION] = qml_import_version[0]
result[QML_IMPORT_MINOR_VERSION] = qml_import_version[1]
qt_modules = visitor.qt_modules()
if qt_modules:
result[QT_MODULES] = qt_modules
if not suppress_file:
result["inputFile"] = os.fspath(file).replace("\\", "/")
return result
if __name__ == '__main__':
arg_parser = create_arg_parser(DESCRIPTION)
args = arg_parser.parse_args()
context = VisitorContext()
json_list = []
for file_name in args.files:
file = Path(file_name).resolve()
if not file.is_file():
print(f'{file_name} does not exist or is not a file.',
file=sys.stderr)
sys.exit(-1)
try:
json_data = parse_file(file, context, args.suppress_file)
if json_data:
json_list.append(json_data)
elif not args.quiet:
print(f"No classes found in {file_name}", file=sys.stderr)
except (AttributeError, SyntaxError) as e:
reason = str(e)
print(f"Error parsing {file_name}: {reason}", file=sys.stderr)
raise
indent = None if args.compact else 4
if args.out_file:
with open(args.out_file, 'w') as f:
json.dump(json_list, f, indent=indent)
else:
json.dump(json_list, sys.stdout, indent=indent)

View File

@@ -0,0 +1,348 @@
# 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 os
from pathlib import Path
from argparse import ArgumentParser, RawTextHelpFormatter
from project_lib import (QmlProjectData, check_qml_decorators, is_python_file, migrate_pyproject,
QMLDIR_FILE, MOD_CMD, METATYPES_JSON_SUFFIX, SHADER_SUFFIXES,
TRANSLATION_SUFFIX, requires_rebuild, run_command, remove_path,
ProjectData, resolve_valid_project_file, new_project, NewProjectTypes,
ClOptions, DesignStudioProject)
DESCRIPTION = """
pyside6-project is a command line tool for creating, building and deploying Qt for Python
applications. It operates on project files which are also used by Qt Creator.
Official documentation:
https://doc.qt.io/qtforpython-6/tools/pyside-project.html
"""
OPERATION_HELP = {
"build": "Build the project. Compiles resources, UI files, and QML files if existing and "
"necessary.",
"run": "Build and run the project.",
"clean": "Clean build artifacts and generated files from the project directory.",
"qmllint": "Run the qmllint tool on QML files in the project.",
"deploy": "Create a deployable package of the application including all dependencies.",
"lupdate": "Update translation files (.ts) with new strings from source files.",
"migrate-pyproject": "Migrate a *.pyproject file to pyproject.toml format."
}
UIC_CMD = "pyside6-uic"
RCC_CMD = "pyside6-rcc"
LRELEASE_CMD = "pyside6-lrelease"
LUPDATE_CMD = "pyside6-lupdate"
QMLTYPEREGISTRAR_CMD = "pyside6-qmltyperegistrar"
QMLLINT_CMD = "pyside6-qmllint"
QSB_CMD = "pyside6-qsb"
DEPLOY_CMD = "pyside6-deploy"
def _sort_sources(files: list[Path]) -> list[Path]:
"""Sort the sources for building, ensure .qrc is last since it might depend
on generated files."""
def key_func(p: Path):
return p.suffix if p.suffix != ".qrc" else ".zzzz"
return sorted(files, key=key_func)
class Project:
"""
Class to wrap the various operations on Project
"""
def __init__(self, project_file: Path):
self.project = ProjectData(project_file=project_file)
self.cl_options = ClOptions()
# Files for QML modules using the QmlElement decorators
self._qml_module_sources: list[Path] = []
self._qml_module_dir: Path | None = None
self._qml_dir_file: Path | None = None
self._qml_project_data = QmlProjectData()
self._qml_module_check()
def _qml_module_check(self):
"""Run a pre-check on Python source files and find the ones with QML
decorators (representing a QML module)."""
# Quick check for any QML files (to avoid running moc for no reason).
if not self.cl_options.qml_module and not self.project.qml_files:
return
for file in self.project.files:
if is_python_file(file):
has_class, data = check_qml_decorators(file)
if has_class:
self._qml_module_sources.append(file)
if data:
self._qml_project_data = data
if not self._qml_module_sources:
return
if not self._qml_project_data:
print("Detected QML-decorated files, " "but was unable to detect QML_IMPORT_NAME")
sys.exit(1)
self._qml_module_dir = self.project.project_file.parent
for uri_dir in self._qml_project_data.import_name.split("."):
self._qml_module_dir /= uri_dir
print(self._qml_module_dir)
self._qml_dir_file = self._qml_module_dir / QMLDIR_FILE
if not self.cl_options.quiet:
count = len(self._qml_module_sources)
print(f"{self.project.project_file.name}, {count} QML file(s),"
f" {self._qml_project_data}")
def _get_artifacts(self, file: Path, output_path: Path | None = None) -> \
tuple[list[Path], list[str] | None]:
"""Return path and command for a file's artifact"""
if file.suffix == ".ui": # Qt form files
py_file = f"{file.parent}/ui_{file.stem}.py"
return [Path(py_file)], [UIC_CMD, os.fspath(file), "--rc-prefix", "-o", py_file]
if file.suffix == ".qrc": # Qt resources
if not output_path:
py_file = f"{file.parent}/rc_{file.stem}.py"
else:
py_file = str(output_path.resolve())
return [Path(py_file)], [RCC_CMD, os.fspath(file), "-o", py_file]
# generate .qmltypes from sources with Qml decorators
if file.suffix == ".py" and file in self._qml_module_sources:
assert self._qml_module_dir
qml_module_dir = os.fspath(self._qml_module_dir)
json_file = f"{qml_module_dir}/{file.stem}{METATYPES_JSON_SUFFIX}"
return [Path(json_file)], [MOD_CMD, "-o", json_file, os.fspath(file)]
# Run qmltyperegistrar
if file.name.endswith(METATYPES_JSON_SUFFIX):
assert self._qml_module_dir
stem = file.name[: len(file.name) - len(METATYPES_JSON_SUFFIX)]
qmltypes_file = self._qml_module_dir / f"{stem}.qmltypes"
cpp_file = self._qml_module_dir / f"{stem}_qmltyperegistrations.cpp"
cmd = [QMLTYPEREGISTRAR_CMD, "--generate-qmltypes",
os.fspath(qmltypes_file), "-o", os.fspath(cpp_file),
os.fspath(file)]
cmd.extend(self._qml_project_data.registrar_options())
return [qmltypes_file, cpp_file], cmd
if file.name.endswith(TRANSLATION_SUFFIX):
qm_file = f"{file.parent}/{file.stem}.qm"
cmd = [LRELEASE_CMD, os.fspath(file), "-qm", qm_file]
return [Path(qm_file)], cmd
if file.suffix in SHADER_SUFFIXES:
qsb_file = f"{file.parent}/{file.stem}.qsb"
cmd = [QSB_CMD, "-o", qsb_file, os.fspath(file)]
return [Path(qsb_file)], cmd
return [], None
def _regenerate_qmldir(self):
"""Regenerate the 'qmldir' file."""
if self.cl_options.dry_run or not self._qml_dir_file:
return
if self.cl_options.force or requires_rebuild(self._qml_module_sources, self._qml_dir_file):
with self._qml_dir_file.open("w") as qf:
qf.write(f"module {self._qml_project_data.import_name}\n")
for f in self._qml_module_dir.glob("*.qmltypes"):
qf.write(f"typeinfo {f.name}\n")
def _build_file(self, source: Path, output_path: Path | None = None):
"""Build an artifact if necessary."""
artifacts, command = self._get_artifacts(source, output_path)
for artifact in artifacts:
if self.cl_options.force or requires_rebuild([source], artifact):
run_command(command, cwd=self.project.project_file.parent)
self._build_file(artifact) # Recurse for QML (json->qmltypes)
def build_design_studio_resources(self):
"""
The resources that need to be compiled are defined in autogen/settings.py
"""
ds_project = DesignStudioProject(self.project.main_file)
if (resources_file_path := ds_project.get_resource_file_path()) is None:
return
compiled_resources_file_path = ds_project.get_compiled_resources_file_path()
self._build_file(resources_file_path, compiled_resources_file_path)
def build(self):
"""Build the whole project"""
for sub_project_file in self.project.sub_projects_files:
Project(project_file=sub_project_file).build()
if self._qml_module_dir:
self._qml_module_dir.mkdir(exist_ok=True, parents=True)
for file in _sort_sources(self.project.files):
self._build_file(file)
if DesignStudioProject.is_ds_project(self.project.main_file):
self.build_design_studio_resources()
self._regenerate_qmldir()
def run(self) -> int:
"""Runs the project"""
self.build()
cmd = [sys.executable, str(self.project.main_file)]
return run_command(cmd, cwd=self.project.project_file.parent)
def _clean_file(self, source: Path):
"""Clean an artifact."""
artifacts, command = self._get_artifacts(source)
for artifact in artifacts:
remove_path(artifact)
self._clean_file(artifact) # Recurse for QML (json->qmltypes)
def clean(self):
"""Clean build artifacts."""
for sub_project_file in self.project.sub_projects_files:
Project(project_file=sub_project_file).clean()
for file in self.project.files:
self._clean_file(file)
if self._qml_module_dir and self._qml_module_dir.is_dir():
remove_path(self._qml_module_dir)
# In case of a dir hierarchy ("a.b" -> a/b), determine and delete
# the root directory
if self._qml_module_dir.parent != self.project.project_file.parent:
project_dir_parts = len(self.project.project_file.parent.parts)
first_module_dir = self._qml_module_dir.parts[project_dir_parts]
remove_path(self.project.project_file.parent / first_module_dir)
if DesignStudioProject.is_ds_project(self.project.main_file):
DesignStudioProject(self.project.main_file).clean()
def _qmllint(self):
"""Helper for running qmllint on .qml files (non-recursive)."""
if not self.project.qml_files:
print(f"{self.project.project_file.name}: No QML files found", file=sys.stderr)
return
cmd = [QMLLINT_CMD]
if self._qml_dir_file:
cmd.extend(["-i", os.fspath(self._qml_dir_file)])
for f in self.project.qml_files:
cmd.append(os.fspath(f))
run_command(cmd, cwd=self.project.project_file.parent, ignore_fail=True)
def qmllint(self):
"""Run qmllint on .qml files."""
self.build()
for sub_project_file in self.project.sub_projects_files:
Project(project_file=sub_project_file)._qmllint()
self._qmllint()
def deploy(self):
"""Deploys the application"""
cmd = [DEPLOY_CMD]
cmd.extend([str(self.project.main_file), "-f"])
run_command(cmd, cwd=self.project.project_file.parent)
def lupdate(self):
for sub_project_file in self.project.sub_projects_files:
Project(project_file=sub_project_file).lupdate()
if not self.project.ts_files:
print(f"{self.project.project_file.name}: No .ts file found.",
file=sys.stderr)
return
source_files = self.project.python_files + self.project.ui_files
project_dir = self.project.project_file.parent
cmd_prefix = [LUPDATE_CMD] + [os.fspath(p.relative_to(project_dir)) for p in source_files]
cmd_prefix.append("-ts")
for ts_file in self.project.ts_files:
ts_dir = ts_file.parent
if not ts_dir.exists():
ts_dir.mkdir(parents=True, exist_ok=True)
if requires_rebuild(source_files, ts_file):
cmd = cmd_prefix
cmd.append(os.fspath(ts_file))
run_command(cmd, cwd=project_dir)
def main(mode: str = None, dry_run: bool = False, quiet: bool = False, force: bool = False,
qml_module: bool = None, project_dir: str = None, project_path: str = None,
legacy_pyproject: bool = False):
cl_options = ClOptions(dry_run=dry_run, quiet=quiet, # noqa: F841
force=force, qml_module=qml_module)
if new_project_type := NewProjectTypes.find_by_command(mode):
if not project_dir:
print(f"Error creating new project: {mode} requires a directory name or path",
file=sys.stderr)
sys.exit(1)
project_dir = Path(project_dir)
try:
project_dir.resolve()
project_dir.mkdir(parents=True, exist_ok=True)
except (OSError, RuntimeError, ValueError):
print("Invalid project name", file=sys.stderr)
sys.exit(1)
sys.exit(new_project(project_dir, new_project_type, legacy_pyproject))
if mode == "migrate-pyproject":
sys.exit(migrate_pyproject(project_path))
try:
project_file = resolve_valid_project_file(project_path)
except ValueError as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
project = Project(project_file)
if mode == "build":
project.build()
elif mode == "run":
sys.exit(project.run())
elif mode == "clean":
project.clean()
elif mode == "qmllint":
project.qmllint()
elif mode == "deploy":
project.deploy()
elif mode == "lupdate":
project.lupdate()
else:
print(f"Invalid mode {mode}", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
parser = ArgumentParser(description=DESCRIPTION, formatter_class=RawTextHelpFormatter)
parser.add_argument("--quiet", "-q", action="store_true", help="Quiet")
parser.add_argument("--dry-run", "-n", action="store_true", help="Only print commands")
parser.add_argument("--force", "-f", action="store_true", help="Force rebuild")
parser.add_argument("--qml-module", "-Q", action="store_true",
help="Perform check for QML module")
# Create subparsers for the two different command branches
subparsers = parser.add_subparsers(dest='mode', required=True)
# Add subparser for project creation commands
for project_type in NewProjectTypes:
new_parser = subparsers.add_parser(project_type.value.command,
help=project_type.value.description)
new_parser.add_argument(
"project_dir", help="Name or location of the new project", nargs="?", type=str)
new_parser.add_argument(
"--legacy-pyproject", action="store_true", help="Create a legacy *.pyproject file")
# Add subparser for project operation commands
for op_mode, op_help in OPERATION_HELP.items():
op_parser = subparsers.add_parser(op_mode, help=op_help)
op_parser.add_argument("project_path", nargs="?", type=str, help="Path to the project file")
args = parser.parse_args()
main(args.mode, args.dry_run, args.quiet, args.force, args.qml_module,
getattr(args, "project_dir", None), getattr(args, "project_path", None),
getattr(args, "legacy_pyproject", None))

View File

@@ -0,0 +1,53 @@
# 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
from dataclasses import dataclass
QTPATHS_CMD = "qtpaths6"
MOD_CMD = "pyside6-metaobjectdump"
PYPROJECT_TOML_PATTERN = "pyproject.toml"
PYPROJECT_JSON_PATTERN = "*.pyproject"
# Note that the order is important, as the first pattern that matches is used
PYPROJECT_FILE_PATTERNS = [PYPROJECT_TOML_PATTERN, PYPROJECT_JSON_PATTERN]
QMLDIR_FILE = "qmldir"
QML_IMPORT_NAME = "QML_IMPORT_NAME"
QML_IMPORT_MAJOR_VERSION = "QML_IMPORT_MAJOR_VERSION"
QML_IMPORT_MINOR_VERSION = "QML_IMPORT_MINOR_VERSION"
QT_MODULES = "QT_MODULES"
METATYPES_JSON_SUFFIX = "metatypes.json"
TRANSLATION_SUFFIX = ".ts"
SHADER_SUFFIXES = ".vert", ".frag"
class Singleton(type):
_instances = {}
def __call__(cls, *args, **kwargs):
if cls not in cls._instances:
cls._instances[cls] = super().__call__(*args, **kwargs)
return cls._instances[cls]
@dataclass(frozen=True)
class ClOptions(metaclass=Singleton):
"""
Dataclass to store the cl options that needs to be passed as arguments.
"""
dry_run: bool
quiet: bool
force: bool
qml_module: bool
from .utils import (run_command, requires_rebuild, remove_path, package_dir, qtpaths,
qt_metatype_json_dir, resolve_valid_project_file)
from .project_data import (is_python_file, ProjectData, QmlProjectData,
check_qml_decorators)
from .newproject import new_project, NewProjectTypes
from .design_studio_project import DesignStudioProject
from .pyproject_toml import parse_pyproject_toml, write_pyproject_toml, migrate_pyproject
from .pyproject_json import parse_pyproject_json

View File

@@ -0,0 +1,65 @@
# Copyright (C) 2024 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR LGPL-3.0-only OR GPL-2.0-only OR GPL-3.0-only
import logging
from pathlib import Path
from typing import Optional
class DesignStudioProject:
"""
Class to handle Design Studio projects. The project structure is as follows:
- Python folder
- autogen folder
- settings.py
- resources.py (Compiled resources)
- main.py
<ProjectName>.qrc (Resources collection file)
<ProjectName>.qmlproject
<ProjectName>.qmlproject.qtds (should be added to .gitignore)
... Other files and folders ...
"""
def __init__(self, main_file: Path):
self.main_file = main_file
self.project_dir = main_file.parent.parent
self.compiled_resources_file = self.main_file.parent / "autogen" / "resources.py"
@staticmethod
def is_ds_project(main_file: Path) -> bool:
return bool(*main_file.parent.parent.glob("*.qmlproject"))
def compiled_resources_available(self) -> bool:
"""
Returns whether the resources of the project have been compiled into a .py file.
TODO: Make the resources path configurable. Wait for the pyproject TOML configuration
"""
return self.compiled_resources_file.exists()
def get_resource_file_path(self) -> Optional[Path]:
"""
Return the path to the *.qrc resources file from the project root folder.
If not found, log an error message and return None
If multiple files are found, log an error message and return None
If a single file is found, return its path
"""
resource_files = list(self.project_dir.glob("*.qrc"))
if not resource_files:
logging.error("No *.qrc resources file found in the project root folder")
return None
if len(resource_files) > 1:
logging.error("Multiple *.qrc resources files found in the project root folder")
return None
return resource_files[0]
def get_compiled_resources_file_path(self) -> Path:
"""
Return the path of the output file generated by compiling the *.qrc resources file
"""
# TODO: make this more robust and configurable. Wait for the pyproject TOML configuration
return self.main_file.parent / "autogen" / "resources.py"
def clean(self):
"""
Remove the compiled resources file if it exists
"""
self.compiled_resources_file.unlink(missing_ok=True)

View File

@@ -0,0 +1,189 @@
# 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 os
import sys
from dataclasses import dataclass
from enum import Enum
from pathlib import Path
from .pyproject_toml import write_pyproject_toml
from .pyproject_json import write_pyproject_json
"""New project generation code."""
_WIDGET_MAIN = """if __name__ == '__main__':
app = QApplication(sys.argv)
window = MainWindow()
window.show()
sys.exit(app.exec())
"""
_WIDGET_IMPORTS = """import sys
from PySide6.QtWidgets import QApplication, QMainWindow
"""
_WIDGET_CLASS_DEFINITION = """class MainWindow(QMainWindow):
def __init__(self):
super().__init__()
"""
_WIDGET_SETUP_UI_CODE = """ self._ui = Ui_MainWindow()
self._ui.setupUi(self)
"""
_MAINWINDOW_FORM = """<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>MainWindow</class>
<widget class="QMainWindow" name="MainWindow">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>800</width>
<height>600</height>
</rect>
</property>
<property name="windowTitle">
<string>MainWindow</string>
</property>
<widget class="QWidget" name="centralwidget"/>
<widget class="QMenuBar" name="menubar">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>800</width>
<height>22</height>
</rect>
</property>
</widget>
<widget class="QStatusBar" name="statusbar"/>
</widget>
</ui>
"""
_QUICK_FORM = """import QtQuick
import QtQuick.Controls
ApplicationWindow {
id: window
width: 1024
height: 600
visible: true
}
"""
_QUICK_MAIN = """import sys
from pathlib import Path
from PySide6.QtGui import QGuiApplication
from PySide6.QtCore import QUrl
from PySide6.QtQml import QQmlApplicationEngine
if __name__ == "__main__":
app = QGuiApplication()
engine = QQmlApplicationEngine()
qml_file = Path(__file__).parent / 'main.qml'
engine.load(QUrl.fromLocalFile(qml_file))
if not engine.rootObjects():
sys.exit(-1)
exit_code = app.exec()
del engine
sys.exit(exit_code)
"""
NewProjectFiles = list[tuple[str, str]] # tuple of (filename, contents).
@dataclass(frozen=True)
class NewProjectType:
command: str
description: str
files: NewProjectFiles
def _write_project(directory: Path, files: NewProjectFiles, legacy_pyproject: bool):
"""
Create the project files in the specified directory.
:param directory: The directory to create the project in.
:param files: The files that belong to the project to create.
"""
file_names = []
for file_name, contents in files:
(directory / file_name).write_text(contents)
print(f"Wrote {directory.name}{os.sep}{file_name}.")
file_names.append(file_name)
if legacy_pyproject:
pyproject_file = directory / f"{directory.name}.pyproject"
write_pyproject_json(pyproject_file, file_names)
else:
pyproject_file = directory / "pyproject.toml"
write_pyproject_toml(pyproject_file, directory.name, file_names)
print(f"Wrote {pyproject_file}.")
def _widget_project() -> NewProjectFiles:
"""Create a (form-less) widgets project."""
main_py = (_WIDGET_IMPORTS + "\n\n" + _WIDGET_CLASS_DEFINITION + "\n\n"
+ _WIDGET_MAIN)
return [("main.py", main_py)]
def _ui_form_project() -> NewProjectFiles:
"""Create a Qt Designer .ui form based widgets project."""
main_py = (_WIDGET_IMPORTS
+ "\nfrom ui_mainwindow import Ui_MainWindow\n\n\n"
+ _WIDGET_CLASS_DEFINITION + _WIDGET_SETUP_UI_CODE
+ "\n\n" + _WIDGET_MAIN)
return [("main.py", main_py),
("mainwindow.ui", _MAINWINDOW_FORM)]
def _qml_project() -> NewProjectFiles:
"""Create a QML project."""
return [("main.py", _QUICK_MAIN),
("main.qml", _QUICK_FORM)]
class NewProjectTypes(Enum):
QUICK = NewProjectType("new-quick", "Create a new Qt Quick project", _qml_project())
WIDGET_FORM = NewProjectType("new-ui", "Create a new Qt Widgets Form project",
_ui_form_project())
WIDGET = NewProjectType("new-widget", "Create a new Qt Widgets project", _widget_project())
@staticmethod
def find_by_command(command: str) -> NewProjectType | None:
return next((pt.value for pt in NewProjectTypes if pt.value.command == command), None)
def new_project(
project_dir: Path, project_type: NewProjectType, legacy_pyproject: bool
) -> int:
"""
Create a new project at the specified project_dir directory.
:param project_dir: The directory path to create the project. If existing, must be empty.
:param project_type: The Qt type of project to create (Qt Widgets, Qt Quick, etc.)
:return: 0 if the project was created successfully, otherwise 1.
"""
if any(project_dir.iterdir()):
print(f"Can not create project at {project_dir}: directory is not empty.", file=sys.stderr)
return 1
project_dir.mkdir(parents=True, exist_ok=True)
try:
_write_project(project_dir, project_type.files, legacy_pyproject)
except Exception as e:
print(f"Error creating project file: {str(e)}", file=sys.stderr)
return 1
if project_type == NewProjectTypes.WIDGET_FORM:
print(f'Run "pyside6-project build {project_dir}" to build the project')
print(f'Run "pyside6-project run {project_dir / "main.py"}" to run the project')
return 0

View File

@@ -0,0 +1,259 @@
# 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 os
import subprocess
import sys
from pathlib import Path
from . import (METATYPES_JSON_SUFFIX, PYPROJECT_JSON_PATTERN, PYPROJECT_TOML_PATTERN,
PYPROJECT_FILE_PATTERNS, TRANSLATION_SUFFIX, qt_metatype_json_dir, MOD_CMD,
QML_IMPORT_MAJOR_VERSION, QML_IMPORT_MINOR_VERSION, QML_IMPORT_NAME, QT_MODULES)
from .pyproject_toml import parse_pyproject_toml
from .pyproject_json import parse_pyproject_json
def is_python_file(file: Path) -> bool:
return (file.suffix == ".py"
or sys.platform == "win32" and file.suffix == ".pyw")
class ProjectData:
def __init__(self, project_file: Path) -> None:
"""Parse the project file."""
self._project_file = project_file.resolve()
self._sub_projects_files: list[Path] = []
# All sources except subprojects
self._files: list[Path] = []
# QML files
self._qml_files: list[Path] = []
# Python files
self.main_file: Path = None
self._python_files: list[Path] = []
# ui files
self._ui_files: list[Path] = []
# qrc files
self._qrc_files: list[Path] = []
# ts files
self._ts_files: list[Path] = []
if project_file.match(PYPROJECT_JSON_PATTERN):
project_file_data = parse_pyproject_json(project_file)
elif project_file.match(PYPROJECT_TOML_PATTERN):
project_file_data = parse_pyproject_toml(project_file)
else:
print(f"Unknown project file format: {project_file}", file=sys.stderr)
sys.exit(1)
if project_file_data.errors:
print(f"Invalid project file: {project_file}. Errors found:", file=sys.stderr)
for error in project_file_data.errors:
print(f"{error}", file=sys.stderr)
sys.exit(1)
for f in project_file_data.files:
file = Path(project_file.parent / f)
if any(file.match(pattern) for pattern in PYPROJECT_FILE_PATTERNS):
self._sub_projects_files.append(file)
continue
self._files.append(file)
if file.suffix == ".qml":
self._qml_files.append(file)
elif is_python_file(file):
if file.stem == "main":
self.main_file = file
self._python_files.append(file)
elif file.suffix == ".ui":
self._ui_files.append(file)
elif file.suffix == ".qrc":
self._qrc_files.append(file)
elif file.suffix == TRANSLATION_SUFFIX:
self._ts_files.append(file)
if not self.main_file:
self._find_main_file()
@property
def project_file(self):
return self._project_file
@property
def files(self):
return self._files
@property
def main_file(self):
return self._main_file
@main_file.setter
def main_file(self, main_file):
self._main_file = main_file
@property
def python_files(self):
return self._python_files
@property
def ui_files(self):
return self._ui_files
@property
def qrc_files(self):
return self._qrc_files
@property
def qml_files(self):
return self._qml_files
@property
def ts_files(self):
return self._ts_files
@property
def sub_projects_files(self):
return self._sub_projects_files
def _find_main_file(self) -> str:
"""Find the entry point file containing the main function"""
def is_main(file):
return "__main__" in file.read_text(encoding="utf-8")
if not self.main_file:
for python_file in self.python_files:
if is_main(python_file):
self.main_file = python_file
return str(python_file)
# __main__ not found
print(
f"Python file with main function not found. Add the file to {self.project_file}",
file=sys.stderr,
)
sys.exit(1)
class QmlProjectData:
"""QML relevant project data."""
def __init__(self):
self._import_name: str = ""
self._import_major_version: int = 0
self._import_minor_version: int = 0
self._qt_modules: list[str] = []
def registrar_options(self):
result = [
"--import-name",
self._import_name,
"--major-version",
str(self._import_major_version),
"--minor-version",
str(self._import_minor_version),
]
if self._qt_modules:
# Add Qt modules as foreign types
foreign_files: list[str] = []
meta_dir = qt_metatype_json_dir()
for mod in self._qt_modules:
mod_id = mod[2:].lower()
pattern = f"qt6{mod_id}_*"
if sys.platform != "win32":
pattern += "_" # qt6core_debug_metatypes.json (Linux)
pattern += METATYPES_JSON_SUFFIX
for f in meta_dir.glob(pattern):
foreign_files.append(os.fspath(f))
break
if foreign_files:
foreign_files_str = ",".join(foreign_files)
result.append(f"--foreign-types={foreign_files_str}")
return result
@property
def import_name(self):
return self._import_name
@import_name.setter
def import_name(self, n):
self._import_name = n
@property
def import_major_version(self):
return self._import_major_version
@import_major_version.setter
def import_major_version(self, v):
self._import_major_version = v
@property
def import_minor_version(self):
return self._import_minor_version
@import_minor_version.setter
def import_minor_version(self, v):
self._import_minor_version = v
@property
def qt_modules(self):
return self._qt_modules
@qt_modules.setter
def qt_modules(self, v):
self._qt_modules = v
def __str__(self) -> str:
vmaj = self._import_major_version
vmin = self._import_minor_version
return f'"{self._import_name}" v{vmaj}.{vmin}'
def __bool__(self) -> bool:
return len(self._import_name) > 0 and self._import_major_version > 0
def _has_qml_decorated_class(class_list: list) -> bool:
"""Check for QML-decorated classes in the moc json output."""
for d in class_list:
class_infos = d.get("classInfos")
if class_infos:
for e in class_infos:
if "QML" in e["name"]:
return True
return False
def check_qml_decorators(py_file: Path) -> tuple[bool, QmlProjectData]:
"""Check if a Python file has QML-decorated classes by running a moc check
and return whether a class was found and the QML data."""
data = None
try:
cmd = [MOD_CMD, "--quiet", os.fspath(py_file)]
with subprocess.Popen(cmd, stdout=subprocess.PIPE) as proc:
data = json.load(proc.stdout)
proc.wait()
except Exception as e:
t = type(e).__name__
print(f"{t}: running {MOD_CMD} on {py_file}: {e}", file=sys.stderr)
sys.exit(1)
qml_project_data = QmlProjectData()
if not data:
return (False, qml_project_data) # No classes in file
first = data[0]
class_list = first["classes"]
has_class = _has_qml_decorated_class(class_list)
if has_class:
v = first.get(QML_IMPORT_NAME)
if v:
qml_project_data.import_name = v
v = first.get(QML_IMPORT_MAJOR_VERSION)
if v:
qml_project_data.import_major_version = v
qml_project_data.import_minor_version = first.get(QML_IMPORT_MINOR_VERSION)
v = first.get(QT_MODULES)
if v:
qml_project_data.qt_modules = v
return (has_class, qml_project_data)

View File

@@ -0,0 +1,58 @@
# Copyright (C) 2025 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR LGPL-3.0-only OR GPL-2.0-only OR GPL-3.0-only
import json
from pathlib import Path
from .pyproject_parse_result import PyProjectParseResult
def write_pyproject_json(pyproject_file: Path, project_files: list[str]):
"""
Create or update a *.pyproject file with the specified content.
:param pyproject_file: The *.pyproject file path to create or update.
:param project_files: The relative paths of the files to include in the project.
"""
# The content of the file is fully replaced, so it is not necessary to read and merge any
# existing content
content = {
"files": sorted(project_files),
}
pyproject_file.write_text(json.dumps(content), encoding="utf-8")
def parse_pyproject_json(pyproject_json_file: Path) -> PyProjectParseResult:
"""
Parse a pyproject.json file and return a PyProjectParseResult object.
"""
result = PyProjectParseResult()
try:
with pyproject_json_file.open("r") as pyf:
project_file_data = json.load(pyf)
except json.JSONDecodeError as e:
result.errors.append(str(e))
return result
except Exception as e:
result.errors.append(str(e))
return result
if not isinstance(project_file_data, dict):
result.errors.append("The root element of pyproject.json must be a JSON object")
return result
found_files = project_file_data.get("files")
if found_files and not isinstance(found_files, list):
result.errors.append("The files element must be a list")
return result
for file in project_file_data.get("files", []):
if not isinstance(file, str):
result.errors.append(f"Invalid file: {file}")
return result
file_path = Path(file)
if not file_path.is_absolute():
file_path = (pyproject_json_file.parent / file).resolve()
result.files.append(file_path)
return result

View File

@@ -0,0 +1,10 @@
# Copyright (C) 2025 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR LGPL-3.0-only OR GPL-2.0-only OR GPL-3.0-only
from dataclasses import dataclass, field
from pathlib import Path
@dataclass
class PyProjectParseResult:
errors: list[str] = field(default_factory=list)
files: list[Path] = field(default_factory=list)

View File

@@ -0,0 +1,275 @@
# Copyright (C) 2025 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 os
import sys
# TODO: Remove this import when Python 3.11 is the minimum supported version
if sys.version_info >= (3, 11):
import tomllib
from pathlib import Path
from . import PYPROJECT_JSON_PATTERN
from .pyproject_parse_result import PyProjectParseResult
from .pyproject_json import parse_pyproject_json
def _parse_toml_content(content: str) -> dict:
"""
Parse TOML content for project name and files list only.
"""
result = {"project": {}, "tool": {"pyside6-project": {}}}
current_section = None
for line in content.splitlines():
line = line.strip()
if not line or line.startswith('#'):
continue
if line == '[project]':
current_section = 'project'
elif line == '[tool.pyside6-project]':
current_section = 'tool.pyside6-project'
elif '=' in line and current_section:
key, value = [part.strip() for part in line.split('=', 1)]
# Handle string values - name of the project
if value.startswith('"') and value.endswith('"'):
value = value[1:-1]
# Handle array of strings - files names
elif value.startswith('[') and value.endswith(']'):
items = value[1:-1].split(',')
value = [item.strip().strip('"') for item in items if item.strip()]
if current_section == 'project':
result['project'][key] = value
else: # tool.pyside6-project
result['tool']['pyside6-project'][key] = value
return result
def _write_base_toml_content(data: dict) -> str:
"""
Write minimal TOML content with project and tool.pyside6-project sections.
"""
lines = []
if data.get('project'):
lines.append('[project]')
for key, value in sorted(data['project'].items()):
if isinstance(value, str):
lines.append(f'{key} = "{value}"')
if data.get("tool") and data['tool'].get('pyside6-project'):
lines.append('\n[tool.pyside6-project]')
for key, value in sorted(data['tool']['pyside6-project'].items()):
if isinstance(value, list):
items = [f'"{item}"' for item in sorted(value)]
lines.append(f'{key} = [{", ".join(items)}]')
else:
lines.append(f'{key} = "{value}"')
return '\n'.join(lines)
def parse_pyproject_toml(pyproject_toml_file: Path) -> PyProjectParseResult:
"""
Parse a pyproject.toml file and return a PyProjectParseResult object.
"""
result = PyProjectParseResult()
try:
content = pyproject_toml_file.read_text(encoding='utf-8')
# TODO: Remove the manual parsing when Python 3.11 is the minimum supported version
if sys.version_info >= (3, 11):
root_table = tomllib.loads(content) # Use tomllib for Python >= 3.11
print("Using tomllib for parsing TOML content")
else:
root_table = _parse_toml_content(content) # Fallback to manual parsing
except Exception as e:
result.errors.append(str(e))
return result
pyside_table = root_table.get("tool", {}).get("pyside6-project", {})
if not pyside_table:
result.errors.append("Missing [tool.pyside6-project] table")
return result
files = pyside_table.get("files", [])
if not isinstance(files, list):
result.errors.append("Missing or invalid files list")
return result
# Convert paths
for file in files:
if not isinstance(file, str):
result.errors.append(f"Invalid file: {file}")
return result
file_path = Path(file)
if not file_path.is_absolute():
file_path = (pyproject_toml_file.parent / file).resolve()
result.files.append(file_path)
return result
def write_pyproject_toml(pyproject_file: Path, project_name: str, project_files: list[str]):
"""
Create or overwrite a pyproject.toml file with the specified content.
"""
data = {
"project": {"name": project_name},
"tool": {
"pyside6-project": {"files": sorted(project_files)}
}
}
content = _write_base_toml_content(data)
try:
pyproject_file.write_text(content, encoding='utf-8')
except Exception as e:
raise ValueError(f"Error writing TOML file: {str(e)}")
def robust_relative_to_posix(target_path: Path, base_path: Path) -> str:
"""
Calculates the relative path from base_path to target_path.
Uses Path.relative_to first, falls back to os.path.relpath if it fails.
Returns the result as a POSIX path string.
"""
# Ensure both paths are absolute for reliable calculation, although in this specific code,
# project_folder and paths in output_files are expected to be resolved/absolute already.
abs_target = target_path.resolve() if not target_path.is_absolute() else target_path
abs_base = base_path.resolve() if not base_path.is_absolute() else base_path
try:
return abs_target.relative_to(abs_base).as_posix()
except ValueError:
# Fallback to os.path.relpath which is more robust for paths that are not direct subpaths.
relative_str = os.path.relpath(str(abs_target), str(abs_base))
# Convert back to Path temporarily to get POSIX format
return Path(relative_str).as_posix()
def migrate_pyproject(pyproject_file: Path | str = None) -> int:
"""
Migrate a project *.pyproject JSON file to the new pyproject.toml format.
The containing subprojects are migrated recursively.
:return: 0 if successful, 1 if an error occurred.
"""
project_name = None
# Transform the user input string into a Path object
if isinstance(pyproject_file, str):
pyproject_file = Path(pyproject_file)
if pyproject_file:
if not pyproject_file.match(PYPROJECT_JSON_PATTERN):
print(f"Cannot migrate non \"{PYPROJECT_JSON_PATTERN}\" file:", file=sys.stderr)
print(f"\"{pyproject_file}\"", file=sys.stderr)
return 1
project_files = [pyproject_file]
project_name = pyproject_file.stem
else:
# Get the existing *.pyproject files in the current directory
project_files = list(Path().glob(PYPROJECT_JSON_PATTERN))
if not project_files:
print(f"No project file found in the current directory: {Path()}", file=sys.stderr)
return 1
if len(project_files) > 1:
print("Multiple pyproject files found in the project folder:")
print('\n'.join(str(project_file) for project_file in project_files))
response = input("Continue? y/n: ")
if response.lower().strip() not in {"yes", "y"}:
return 0
else:
# If there is only one *.pyproject file in the current directory,
# use its file name as the project name
project_name = project_files[0].stem
# The project files that will be written to the pyproject.toml file
output_files: set[Path] = set()
for project_file in project_files:
project_data = parse_pyproject_json(project_file)
if project_data.errors:
print(f"Invalid project file: {project_file}. Errors found:", file=sys.stderr)
print('\n'.join(project_data.errors), file=sys.stderr)
return 1
output_files.update(project_data.files)
project_folder = project_files[0].parent.resolve()
if project_name is None:
# If a project name has not resolved, use the name of the parent folder
project_name = project_folder.name
pyproject_toml_file = project_folder / "pyproject.toml"
relative_files = sorted(
robust_relative_to_posix(p, project_folder) for p in output_files
)
if not (already_existing_file := pyproject_toml_file.exists()):
# Create new pyproject.toml file
data = {
"project": {"name": project_name},
"tool": {
"pyside6-project": {"files": relative_files}
}
}
updated_content = _write_base_toml_content(data)
else:
# For an already existing file, append our tool.pyside6-project section
# If the project section is missing, add it
try:
content = pyproject_toml_file.read_text(encoding='utf-8')
except Exception as e:
print(f"Error processing existing TOML file: {str(e)}", file=sys.stderr)
return 1
append_content = []
if '[project]' not in content:
# Add project section if needed
append_content.append('\n[project]')
append_content.append(f'name = "{project_name}"')
if '[tool.pyside6-project]' not in content:
# Add tool.pyside6-project section
append_content.append('\n[tool.pyside6-project]')
items = [f'"{item}"' for item in relative_files]
append_content.append(f'files = [{", ".join(items)}]')
if append_content:
updated_content = content.rstrip() + '\n' + '\n'.join(append_content)
else:
# No changes needed
print("pyproject.toml already contains [project] and [tool.pyside6-project] sections")
return 0
print(f"WARNING: A pyproject.toml file already exists at \"{pyproject_toml_file}\"")
print("The file will be updated with the following content:")
print(updated_content)
response = input("Proceed? [Y/n] ")
if response.lower().strip() not in {"yes", "y"}:
return 0
try:
pyproject_toml_file.write_text(updated_content, encoding='utf-8')
except Exception as e:
print(f"Error writing to \"{pyproject_toml_file}\": {str(e)}", file=sys.stderr)
return 1
if not already_existing_file:
print(f"Created \"{pyproject_toml_file}\"")
else:
print(f"Updated \"{pyproject_toml_file}\"")
# Recursively migrate the subprojects
for sub_project_file in filter(lambda f: f.match(PYPROJECT_JSON_PATTERN), output_files):
result = migrate_pyproject(sub_project_file)
if result != 0:
return result
return 0

View File

@@ -0,0 +1,194 @@
# 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 subprocess
import sys
import xml.etree.ElementTree as ET
from pathlib import Path
from . import (QTPATHS_CMD, PYPROJECT_JSON_PATTERN, PYPROJECT_TOML_PATTERN, PYPROJECT_FILE_PATTERNS,
ClOptions)
from .pyproject_toml import parse_pyproject_toml
from .pyproject_json import parse_pyproject_json
def run_command(command: list[str], cwd: str = None, ignore_fail: bool = False) -> int:
"""
Run a command using a subprocess.
If dry run is enabled, the command will be printed to stdout instead of being executed.
:param command: The command to run including the arguments
:param cwd: The working directory to run the command in
:param ignore_fail: If True, the current process will not exit if the command fails
:return: The exit code of the command
"""
cloptions = ClOptions()
if not cloptions.quiet or cloptions.dry_run:
print(" ".join(command))
if cloptions.dry_run:
return 0
ex = subprocess.call(command, cwd=cwd)
if ex != 0 and not ignore_fail:
sys.exit(ex)
return ex
def qrc_file_requires_rebuild(resources_file_path: Path, compiled_resources_path: Path) -> bool:
"""Returns whether a compiled qrc file needs to be rebuilt based on the files that references"""
root_element = ET.parse(resources_file_path).getroot()
project_root = resources_file_path.parent
files = [project_root / file.text for file in root_element.findall(".//file")]
compiled_resources_time = compiled_resources_path.stat().st_mtime
# If any of the resource files has been modified after the compiled qrc file, the compiled qrc
# file needs to be rebuilt
if any(file.is_file() and file.stat().st_mtime > compiled_resources_time for file in files):
return True
return False
def requires_rebuild(sources: list[Path], artifact: Path) -> bool:
"""Returns whether artifact needs to be rebuilt depending on sources"""
if not artifact.is_file():
return True
artifact_mod_time = artifact.stat().st_mtime
for source in sources:
if source.stat().st_mtime > artifact_mod_time:
return True
# The .qrc file references other files that might have changed
if source.suffix == ".qrc" and qrc_file_requires_rebuild(source, artifact):
return True
return False
def _remove_path_recursion(path: Path):
"""Recursion to remove a file or directory."""
if path.is_file():
path.unlink()
elif path.is_dir():
for item in path.iterdir():
_remove_path_recursion(item)
path.rmdir()
def remove_path(path: Path):
"""Remove path (file or directory) observing opt_dry_run."""
cloptions = ClOptions()
if not path.exists():
return
if not cloptions.quiet:
print(f"Removing {path.name}...")
if cloptions.dry_run:
return
_remove_path_recursion(path)
def package_dir() -> Path:
"""Return the PySide6 root."""
return Path(__file__).resolve().parents[2]
_qtpaths_info: dict[str, str] = {}
def qtpaths() -> dict[str, str]:
"""Run qtpaths and return a dict of values."""
global _qtpaths_info
if not _qtpaths_info:
output = subprocess.check_output([QTPATHS_CMD, "--query"])
for line in output.decode("utf-8").split("\n"):
tokens = line.strip().split(":", maxsplit=1) # "Path=C:\..."
if len(tokens) == 2:
_qtpaths_info[tokens[0]] = tokens[1]
return _qtpaths_info
_qt_metatype_json_dir: Path | None = None
def qt_metatype_json_dir() -> Path:
"""Return the location of the Qt QML metatype files."""
global _qt_metatype_json_dir
if not _qt_metatype_json_dir:
qt_dir = package_dir()
if sys.platform != "win32":
qt_dir /= "Qt"
metatypes_dir = qt_dir / "metatypes"
if metatypes_dir.is_dir(): # Fully installed case
_qt_metatype_json_dir = metatypes_dir
else:
# Fallback for distro builds/development.
print(
f"Falling back to {QTPATHS_CMD} to determine metatypes directory.", file=sys.stderr
)
_qt_metatype_json_dir = Path(qtpaths()["QT_INSTALL_ARCHDATA"]) / "metatypes"
return _qt_metatype_json_dir
def resolve_valid_project_file(
project_path_input: str = None, project_file_patterns: list[str] = PYPROJECT_FILE_PATTERNS
) -> Path:
"""
Find a valid project file given a preferred project file name and a list of project file name
patterns for a fallback search.
If the provided file name is a valid project file, return it. Otherwise, search for a known
project file in the current working directory with the given patterns.
Raises a ValueError if no project file is found, multiple project files are found in the same
directory or the provided path is not a valid project file or folder.
:param project_path_input: The command-line argument specifying a project file or folder path.
:param project_file_patterns: The list of project file patterns to search for.
:return: The resolved project file path
"""
if project_path_input and (project_file := Path(project_path_input).resolve()).is_file():
if project_file.match(PYPROJECT_TOML_PATTERN):
if bool(parse_pyproject_toml(project_file).errors):
raise ValueError(f"Invalid project file: {project_file}")
elif project_file.match(PYPROJECT_JSON_PATTERN):
pyproject_json_result = parse_pyproject_json(project_file)
if errors := '\n'.join(str(e) for e in pyproject_json_result.errors):
raise ValueError(f"Invalid project file: {project_file}\n{errors}")
else:
raise ValueError(f"Unknown project file: {project_file}")
return project_file
project_folder = Path.cwd()
if project_path_input:
if not Path(project_path_input).resolve().is_dir():
raise ValueError(f"Invalid project path: {project_path_input}")
project_folder = Path(project_path_input).resolve()
# Search a project file in the project folder using the provided patterns
for pattern in project_file_patterns:
if not (matches := list(project_folder.glob(pattern))):
# No project files found with the specified pattern
continue
if len(matches) > 1:
matched_files = '\n'.join(str(f) for f in matches)
raise ValueError(f"Multiple project files found:\n{matched_files}")
project_file = matches[0]
if pattern == PYPROJECT_TOML_PATTERN:
if parse_pyproject_toml(project_file).errors:
# Invalid file, but a .pyproject file may exist
# We can not raise an error due to ensuring backward compatibility
continue
elif pattern == PYPROJECT_JSON_PATTERN:
pyproject_json_result = parse_pyproject_json(project_file)
if errors := '\n'.join(str(e) for e in pyproject_json_result.errors):
raise ValueError(f"Invalid project file: {project_file}\n{errors}")
# Found a valid project file
return project_file
raise ValueError("No project file found in the current directory")

View File

@@ -0,0 +1,264 @@
#!/usr/bin/env python
# Copyright (C) 2022 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR LGPL-3.0-only OR GPL-2.0-only OR GPL-3.0-only
from __future__ import annotations
import importlib
import os
import subprocess
import sys
import sysconfig
from pathlib import Path
import PySide6 as ref_mod
VIRTUAL_ENV = "VIRTUAL_ENV"
def is_pyenv_python():
pyenv_root = os.environ.get("PYENV_ROOT")
if pyenv_root:
resolved_exe = Path(sys.executable).resolve()
if str(resolved_exe).startswith(pyenv_root):
return True
return False
def is_virtual_env():
return sys.prefix != sys.base_prefix
def init_virtual_env():
"""PYSIDE-2251: Enable running from a non-activated virtual environment
as is the case for Visual Studio Code by setting the VIRTUAL_ENV
variable which is used by the Qt Designer plugin."""
if is_virtual_env() and not os.environ.get(VIRTUAL_ENV):
os.environ[VIRTUAL_ENV] = sys.prefix
def main():
# This will take care of "pyside6-lupdate" listed as an entrypoint
# in setup.py are copied to 'scripts/..'
cmd = os.path.join("..", os.path.basename(sys.argv[0]))
command = [os.path.join(os.path.dirname(os.path.realpath(__file__)), cmd)]
command.extend(sys.argv[1:])
sys.exit(subprocess.call(command))
def qt_tool_wrapper(qt_tool, args, libexec=False):
# Taking care of pyside6-uic, pyside6-rcc, and pyside6-designer
# listed as an entrypoint in setup.py
pyside_dir = Path(ref_mod.__file__).resolve().parent
if libexec and sys.platform != "win32":
exe = pyside_dir / 'Qt' / 'libexec' / qt_tool
else:
exe = pyside_dir / qt_tool
cmd = [os.fspath(exe)] + args
returncode = subprocess.call(cmd)
if returncode != 0:
command = ' '.join(cmd)
print(f"'{command}' returned {returncode}", file=sys.stderr)
sys.exit(returncode)
def pyside_script_wrapper(script_name):
"""Launch a script shipped with PySide."""
script = Path(__file__).resolve().parent / script_name
command = [sys.executable, os.fspath(script)] + sys.argv[1:]
sys.exit(subprocess.call(command))
def ui_tool_binary(binary):
"""Return the binary of a UI tool (App bundle on macOS)."""
if sys.platform != "darwin":
return binary
name = binary[0:1].upper() + binary[1:]
return f"{name}.app/Contents/MacOS/{name}"
def lrelease():
qt_tool_wrapper("lrelease", sys.argv[1:])
def lupdate():
qt_tool_wrapper("lupdate", sys.argv[1:])
def uic():
qt_tool_wrapper("uic", ['-g', 'python'] + sys.argv[1:], True)
def rcc():
args = []
user_args = sys.argv[1:]
if "--binary" not in user_args:
args.extend(['-g', 'python'])
args.extend(user_args)
qt_tool_wrapper("rcc", args, True)
def qmltyperegistrar():
qt_tool_wrapper("qmltyperegistrar", sys.argv[1:], True)
def qmlimportscanner():
qt_tool_wrapper("qmlimportscanner", sys.argv[1:], True)
def qmlcachegen():
qt_tool_wrapper("qmlcachegen", sys.argv[1:], True)
def qmllint():
qt_tool_wrapper("qmllint", sys.argv[1:])
def qmlformat():
qt_tool_wrapper("qmlformat", sys.argv[1:])
def qmlls():
qt_tool_wrapper("qmlls", sys.argv[1:])
def assistant():
qt_tool_wrapper(ui_tool_binary("assistant"), sys.argv[1:])
def _extend_path_var(var, value, prepend=False):
env_value = os.environ.get(var)
if env_value:
env_value = (f'{value}{os.pathsep}{env_value}'
if prepend else f'{env_value}{os.pathsep}{value}')
else:
env_value = value
os.environ[var] = env_value
def designer():
init_virtual_env()
# https://www.python.org/dev/peps/pep-0384/#linkage :
# "On Unix systems, the ABI is typically provided by the python executable
# itself", that is, libshiboken does not link against any Python library
# and expects to get these symbols from a python executable. Since no
# python executable is involved when loading this plugin, pre-load python.so
# This should also help to work around a numpy issue, see
# https://stackoverflow.com/questions/49784583/numpy-import-fails-on-multiarray-extension-library-when-called-from-embedded-pyt
major_version = sys.version_info[0]
minor_version = sys.version_info[1]
os.environ['PY_MAJOR_VERSION'] = str(major_version)
os.environ['PY_MINOR_VERSION'] = str(minor_version)
if sys.platform == 'linux':
# Determine library name (examples/utils/pyside_config.py)
version = f'{major_version}.{minor_version}'
library_name = f'libpython{version}{sys.abiflags}.so'
if is_pyenv_python():
library_name = str(Path(sysconfig.get_config_var('LIBDIR')) / library_name)
os.environ['LD_PRELOAD'] = library_name
elif sys.platform == 'darwin':
library_name = sysconfig.get_config_var("LDLIBRARY")
framework_prefix = sysconfig.get_config_var("PYTHONFRAMEWORKPREFIX")
lib_path = None
if framework_prefix:
lib_path = os.fspath(Path(framework_prefix) / library_name)
elif is_pyenv_python():
lib_path = str(Path(sysconfig.get_config_var('LIBDIR')) / library_name)
else:
# ideally this should never be reached because the system Python and Python installed
# from python.org are all framework builds
print("Unable to find Python library directory. Use a framework build of Python.",
file=sys.stderr)
sys.exit(0)
os.environ['DYLD_INSERT_LIBRARIES'] = lib_path
elif sys.platform == 'win32':
# Find Python DLLs from the base installation
if is_virtual_env():
_extend_path_var("PATH", os.fspath(Path(sys._base_executable).parent), True)
qt_tool_wrapper(ui_tool_binary("designer"), sys.argv[1:])
def linguist():
qt_tool_wrapper(ui_tool_binary("linguist"), sys.argv[1:])
def genpyi():
pyside_dir = Path(__file__).resolve().parents[1]
support = pyside_dir / "support"
cmd = support / "generate_pyi.py"
command = [sys.executable, os.fspath(cmd)] + sys.argv[1:]
sys.exit(subprocess.call(command))
def metaobjectdump():
pyside_script_wrapper("metaobjectdump.py")
def _check_requirements(requirements_file):
"""Check if all required packages are installed."""
missing_packages = []
with open(requirements_file, 'r', encoding='UTF-8') as file:
for line in file:
# versions
package = line.strip().split('==')[0]
if not importlib.util.find_spec(package):
missing_packages.append(line.strip())
return missing_packages
def project():
pyside_script_wrapper("project.py")
def qml():
pyside_script_wrapper("qml.py")
def qtpy2cpp():
pyside_script_wrapper("qtpy2cpp.py")
def deploy():
pyside_script_wrapper("deploy.py")
def android_deploy():
if sys.platform == "win32":
print("pyside6-android-deploy only works from a Unix host and not a Windows host",
file=sys.stderr)
else:
android_requirements_file = Path(__file__).parent / "requirements-android.txt"
if android_requirements_file.exists():
missing_packages = _check_requirements(android_requirements_file)
if missing_packages:
print("The following packages are required but not installed:")
for package in missing_packages:
print(f" - {package}")
print("Please install them using:")
print(f" pip install -r {android_requirements_file}")
sys.exit(1)
pyside_script_wrapper("android_deploy.py")
def qsb():
qt_tool_wrapper("qsb", sys.argv[1:])
def balsam():
qt_tool_wrapper("balsam", sys.argv[1:])
def balsamui():
qt_tool_wrapper("balsamui", sys.argv[1:])
def svgtoqml():
qt_tool_wrapper("svgtoqml", sys.argv[1:])
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,248 @@
# Copyright (C) 2018 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR LGPL-3.0-only OR GPL-2.0-only OR GPL-3.0-only
from __future__ import annotations
"""pyside6-qml tool implementation. This tool mimics the capabilities of qml runtime utility
for python and enables quick protyping with python modules"""
import argparse
import importlib.util
import logging
import sys
import os
from pathlib import Path
from pprint import pprint
from PySide6.QtCore import QCoreApplication, Qt, QLibraryInfo, QUrl, SignalInstance
from PySide6.QtGui import QGuiApplication, QSurfaceFormat
from PySide6.QtQml import QQmlApplicationEngine, QQmlComponent
from PySide6.QtQuick import QQuickView, QQuickItem
from PySide6.QtWidgets import QApplication
def import_qml_modules(qml_parent_path: Path, module_paths: list[Path] = []):
'''
Import all the python modules in the qml_parent_path. This way all the classes
containing the @QmlElement/@QmlNamedElement are also imported
Parameters:
qml_parent_path (Path): Parent directory of the qml file
module_paths (int): user give import paths obtained through cli
'''
search_dir_paths = []
search_file_paths = []
if not module_paths:
search_dir_paths.append(qml_parent_path)
else:
for module_path in module_paths:
if module_path.is_dir():
search_dir_paths.append(module_path)
elif module_path.exists() and module_path.suffix == ".py":
search_file_paths.append(module_path)
def import_module(import_module_paths: set[Path]):
"""Import the modules in 'import_module_paths'"""
for module_path in import_module_paths:
module_name = module_path.name[:-3]
_spec = importlib.util.spec_from_file_location(f"{module_name}", module_path)
_module = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(module=_module)
modules_to_import = set()
for search_path in search_dir_paths:
possible_modules = list(search_path.glob("**/*.py"))
for possible_module in possible_modules:
if possible_module.is_file() and possible_module.name != "__init__.py":
module_parent = str(possible_module.parent)
if module_parent not in sys.path:
sys.path.append(module_parent)
modules_to_import.add(possible_module)
for search_path in search_file_paths:
sys.path.append(str(search_path.parent))
modules_to_import.add(search_path)
import_module(import_module_paths=modules_to_import)
def print_configurations():
return "Built-in configurations \n\t default \n\t resizeToItem"
if __name__ == "__main__":
parser = argparse.ArgumentParser(
description="This tools mimics the capabilities of qml runtime utility by directly"
" invoking QQmlEngine/QQuickView. It enables quick prototyping with qml files.",
formatter_class=argparse.RawTextHelpFormatter
)
parser.add_argument(
"file",
type=lambda p: Path(p).absolute(),
help="Path to qml file to display",
)
parser.add_argument(
"--module-paths", "-I",
type=lambda p: Path(p).absolute(),
nargs="+",
help="Specify space separated folder/file paths where the Qml classes are defined. By"
" default,the parent directory of the qml_path is searched recursively for all .py"
" files and they are imported. Otherwise only the paths give in module paths are"
" searched",
)
parser.add_argument(
"--list-conf",
action="version",
help="List the built-in configurations.",
version=print_configurations()
)
parser.add_argument(
"--apptype", "-a",
choices=["core", "gui", "widget"],
default="gui",
help="Select which application class to use. Default is gui",
)
parser.add_argument(
"--config", "-c",
choices=["default", "resizeToItem"],
default="default",
help="Select the built-in configurations.",
)
parser.add_argument(
"--rhi", "-r",
choices=["vulkan", "metal", "d3dll", "gl"],
help="Set the backend for the Qt graphics abstraction (RHI).",
)
parser.add_argument(
"--core-profile",
action="store_true",
help="Force use of OpenGL Core Profile.",
)
parser.add_argument(
'-v', '--verbose',
help="Print information about what qml is doing, like specific file URLs being loaded.",
action="store_const", dest="loglevel", const=logging.INFO,
)
gl_group = parser.add_mutually_exclusive_group(required=False)
gl_group.add_argument(
"--gles",
action="store_true",
help="Force use of GLES (AA_UseOpenGLES)",
)
gl_group.add_argument(
"--desktop",
action="store_true",
help="Force use of desktop OpenGL (AA_UseDesktopOpenGL)",
)
gl_group.add_argument(
"--software",
action="store_true",
help="Force use of software rendering(AA_UseSoftwareOpenGL)",
)
gl_group.add_argument(
"--disable-context-sharing",
action="store_true",
help=" Disable the use of a shared GL context for QtQuick Windows",
)
args = parser.parse_args()
apptype = args.apptype
qquick_present = False
with open(args.file) as myfile:
if 'import QtQuick' in myfile.read():
qquick_present = True
# no import QtQuick => QQCoreApplication
if not qquick_present:
apptype = "core"
import_qml_modules(args.file.parent, args.module_paths)
logging.basicConfig(level=args.loglevel)
logging.info(f"qml: {QLibraryInfo.build()}")
logging.info(f"qml: Using built-in configuration: {args.config}")
if args.rhi:
os.environ['QSG_RHI_BACKEND'] = args.rhi
logging.info(f"qml: loading {args.file}")
qml_file = QUrl.fromLocalFile(str(args.file))
if apptype == "gui":
if args.gles:
logging.info("qml: Using attribute AA_UseOpenGLES")
QCoreApplication.setAttribute(Qt.AA_UseOpenGLES)
elif args.desktop:
logging.info("qml: Using attribute AA_UseDesktopOpenGL")
QCoreApplication.setAttribute(Qt.AA_UseDesktopOpenGL)
elif args.software:
logging.info("qml: Using attribute AA_UseSoftwareOpenGL")
QCoreApplication.setAttribute(Qt.AA_UseSoftwareOpenGL)
# context-sharing is enabled by default
if not args.disable_context_sharing:
logging.info("qml: Using attribute AA_ShareOpenGLContexts")
QCoreApplication.setAttribute(Qt.AA_ShareOpenGLContexts)
if apptype == "core":
logging.info("qml: Core application")
app = QCoreApplication(sys.argv)
elif apptype == "widgets":
logging.info("qml: Widget application")
app = QApplication(sys.argv)
else:
logging.info("qml: Gui application")
app = QGuiApplication(sys.argv)
engine = QQmlApplicationEngine()
# set OpenGLContextProfile
if apptype == "gui" and args.core_profile:
logging.info("qml: Set profile for QSurfaceFormat as CoreProfile")
surfaceFormat = QSurfaceFormat()
surfaceFormat.setStencilBufferSize(8)
surfaceFormat.setDepthBufferSize(24)
surfaceFormat.setVersion(4, 1)
surfaceFormat.setProfile(QSurfaceFormat.CoreProfile)
QSurfaceFormat.setDefaultFormat(surfaceFormat)
# in the case of QCoreApplication we print the attributes of the object created via
# QQmlComponent and exit
if apptype == "core":
component = QQmlComponent(engine, qml_file)
obj = component.create()
filtered_attributes = {k: v for k, v in vars(obj).items() if type(v) is not SignalInstance}
logging.info("qml: component object attributes are")
pprint(filtered_attributes)
del engine
sys.exit(0)
engine.load(qml_file)
rootObjects = engine.rootObjects()
if not rootObjects:
sys.exit(-1)
qquick_view = False
if isinstance(rootObjects[0], QQuickItem) and qquick_present:
logging.info("qml: loading with QQuickView")
viewer = QQuickView()
viewer.setSource(qml_file)
if args.config != "resizeToItem":
viewer.setResizeMode(QQuickView.SizeRootObjectToView)
else:
viewer.setResizeMode(QQuickView.SizeViewToRootObject)
viewer.show()
qquick_view = True
if not qquick_view:
logging.info("qml: loading with QQmlApplicationEngine")
if args.config == "resizeToItem":
logging.info("qml: Not a QQuickview item. resizeToItem is done by default")
exit_code = app.exec()
del engine
sys.exit(exit_code)

View File

@@ -0,0 +1,63 @@
# Copyright (C) 2022 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR LGPL-3.0-only OR GPL-2.0-only OR GPL-3.0-only
from __future__ import annotations
import logging
import os
import sys
from argparse import ArgumentParser, RawTextHelpFormatter
from pathlib import Path
from qtpy2cpp_lib.visitor import ConvertVisitor
DESCRIPTION = "Tool to convert Python to C++"
def create_arg_parser(desc):
parser = ArgumentParser(description=desc,
formatter_class=RawTextHelpFormatter)
parser.add_argument("--debug", "-d", action="store_true",
help="Debug")
parser.add_argument("--stdout", "-s", action="store_true",
help="Write to stdout")
parser.add_argument("--force", "-f", action="store_true",
help="Force overwrite of existing files")
parser.add_argument("files", type=str, nargs="+", help="Python source file(s)")
return parser
if __name__ == "__main__":
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
arg_parser = create_arg_parser(DESCRIPTION)
args = arg_parser.parse_args()
ConvertVisitor.debug = args.debug
for input_file_str in args.files:
input_file = Path(input_file_str)
if not input_file.is_file():
logger.error(f"{input_file_str} does not exist or is not a file.")
sys.exit(-1)
file_root, ext = os.path.splitext(input_file)
if input_file.suffix != ".py":
logger.error(f"{input_file_str} does not appear to be a Python file.")
sys.exit(-1)
ast_tree = ConvertVisitor.create_ast(input_file_str)
if args.stdout:
sys.stdout.write(f"// Converted from {input_file.name}\n")
ConvertVisitor(input_file, sys.stdout).visit(ast_tree)
else:
target_file = input_file.parent / (input_file.stem + ".cpp")
if target_file.exists():
if not target_file.is_file():
logger.error(f"{target_file} exists and is not a file.")
sys.exit(-1)
if not args.force:
logger.error(f"{target_file} exists. Use -f to overwrite.")
sys.exit(-1)
with target_file.open("w") as file:
file.write(f"// Converted from {input_file.name}\n")
ConvertVisitor(input_file, file).visit(ast_tree)
logger.info(f"Wrote {target_file}.")

View File

@@ -0,0 +1,112 @@
# 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
"""Tool to dump a Python AST"""
import ast
import tokenize
from argparse import ArgumentParser, RawTextHelpFormatter
from enum import Enum
from nodedump import debug_format_node
DESCRIPTION = "Tool to dump a Python AST"
_source_lines = []
_opt_verbose = False
def first_non_space(s):
for i, c in enumerate(s):
if c != ' ':
return i
return 0
class NodeType(Enum):
IGNORE = 1
PRINT_ONE_LINE = 2 # Print as a one liner, do not visit children
PRINT = 3 # Print with opening closing tag, visit children
PRINT_WITH_SOURCE = 4 # Like PRINT, but print source line above
def get_node_type(node):
if isinstance(node, (ast.Load, ast.Store, ast.Delete)):
return NodeType.IGNORE
if isinstance(node, (ast.Add, ast.alias, ast.arg, ast.Eq, ast.Gt, ast.Lt,
ast.Mult, ast.Name, ast.NotEq, ast.NameConstant, ast.Not,
ast.Num, ast.Str)):
return NodeType.PRINT_ONE_LINE
if not hasattr(node, 'lineno'):
return NodeType.PRINT
if isinstance(node, (ast.Attribute)):
return NodeType.PRINT_ONE_LINE if isinstance(node.value, ast.Name) else NodeType.PRINT
return NodeType.PRINT_WITH_SOURCE
class DumpVisitor(ast.NodeVisitor):
def __init__(self):
ast.NodeVisitor.__init__(self)
self._indent = 0
self._printed_source_lines = {-1}
def generic_visit(self, node):
node_type = get_node_type(node)
if _opt_verbose and node_type in (NodeType.IGNORE, NodeType.PRINT_ONE_LINE):
node_type = NodeType.PRINT
if node_type == NodeType.IGNORE:
return
self._indent = self._indent + 1
indent = ' ' * self._indent
if node_type == NodeType.PRINT_WITH_SOURCE:
line_number = node.lineno - 1
if line_number not in self._printed_source_lines:
self._printed_source_lines.add(line_number)
line = _source_lines[line_number]
non_space = first_non_space(line)
print('{:04d} {}{}'.format(line_number, '_' * non_space,
line[non_space:]))
if node_type == NodeType.PRINT_ONE_LINE:
print(indent, debug_format_node(node))
else:
print(indent, '>', debug_format_node(node))
ast.NodeVisitor.generic_visit(self, node)
print(indent, '<', type(node).__name__)
self._indent = self._indent - 1
def parse_ast(filename):
node = None
with tokenize.open(filename) as f:
global _source_lines
source = f.read()
_source_lines = source.split('\n')
node = ast.parse(source, mode="exec")
return node
def create_arg_parser(desc):
parser = ArgumentParser(description=desc,
formatter_class=RawTextHelpFormatter)
parser.add_argument('--verbose', '-v', action='store_true',
help='Verbose')
parser.add_argument('source', type=str, help='Python source')
return parser
if __name__ == '__main__':
arg_parser = create_arg_parser(DESCRIPTION)
options = arg_parser.parse_args()
_opt_verbose = options.verbose
title = f'AST tree for {options.source}'
print('=' * len(title))
print(title)
print('=' * len(title))
tree = parse_ast(options.source)
DumpVisitor().visit(tree)

View File

@@ -0,0 +1,266 @@
# 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
"""C++ formatting helper functions and formatter class"""
import ast
from .qt import ClassFlag, qt_class_flags
CLOSING = {"{": "}", "(": ")", "[": "]"} # Closing parenthesis for C++
def _fix_function_argument_type(type, for_return):
"""Fix function argument/return qualifiers using some heuristics for Qt."""
if type == "float":
return "double"
if type == "str":
type = "QString"
if not type.startswith("Q"):
return type
flags = qt_class_flags(type)
if flags & ClassFlag.PASS_BY_VALUE:
return type
if flags & ClassFlag.PASS_BY_CONSTREF:
return type if for_return else f"const {type} &"
if flags & ClassFlag.PASS_BY_REF:
return type if for_return else f"{type} &"
return type + " *" # Assume pointer by default
def to_string(node):
"""Helper to retrieve a string from the (Lists of)Name/Attribute
aggregated into some nodes"""
if isinstance(node, ast.Name):
return node.id
if isinstance(node, ast.Attribute):
return node.attr
return ''
def format_inheritance(class_def_node):
"""Returns inheritance specification of a class"""
result = ''
for base in class_def_node.bases:
name = to_string(base)
if name != 'object':
result += ', public ' if result else ' : public '
result += name
return result
def format_for_target(target_node):
if isinstance(target_node, ast.Tuple): # for i,e in enumerate()
result = ''
for i, el in enumerate(target_node.elts):
if i > 0:
result += ', '
result += format_reference(el)
return result
return format_reference(target_node)
def format_for_loop(f_node):
"""Format a for loop
This applies some heuristics to detect:
1) "for a in [1,2])" -> "for (f: {1, 2}) {"
2) "for i in range(5)" -> "for (i = 0; i < 5; ++i) {"
3) "for i in range(2,5)" -> "for (i = 2; i < 5; ++i) {"
TODO: Detect other cases, maybe including enumerate().
"""
loop_vars = format_for_target(f_node.target)
result = 'for (' + loop_vars
if isinstance(f_node.iter, ast.Call):
f = format_reference(f_node.iter.func)
if f == 'range':
start = 0
end = -1
if len(f_node.iter.args) == 2:
start = format_literal(f_node.iter.args[0])
end = format_literal(f_node.iter.args[1])
elif len(f_node.iter.args) == 1:
end = format_literal(f_node.iter.args[0])
result += f' = {start}; {loop_vars} < {end}; ++{loop_vars}'
elif isinstance(f_node.iter, ast.List):
# Range based for over list
result += ': ' + format_literal_list(f_node.iter)
elif isinstance(f_node.iter, ast.Name):
# Range based for over variable
result += ': ' + f_node.iter.id
result += ') {'
return result
def format_name_constant(node):
"""Format a ast.NameConstant."""
if node.value is None:
return "nullptr"
return "true" if node.value else "false"
def format_literal(node):
"""Returns the value of number/string literals"""
if isinstance(node, ast.NameConstant):
return format_name_constant(node)
if isinstance(node, ast.Num):
return str(node.n)
if isinstance(node, ast.Str):
# Fixme: escaping
return f'"{node.s}"'
return ''
def format_literal_list(l_node, enclosing='{'):
"""Formats a list/tuple of number/string literals as C++ initializer list"""
result = enclosing
for i, el in enumerate(l_node.elts):
if i > 0:
result += ', '
result += format_literal(el)
result += CLOSING[enclosing]
return result
def format_member(attrib_node, qualifier_in='auto'):
"""Member access foo->member() is expressed as an attribute with
further nested Attributes/Names as value"""
n = attrib_node
result = ''
# Black magic: Guess '::' if name appears to be a class name
qualifier = qualifier_in
if qualifier_in == 'auto':
qualifier = '::' if n.attr[0:1].isupper() else '->'
while isinstance(n, ast.Attribute):
result = n.attr if not result else n.attr + qualifier + result
n = n.value
if isinstance(n, ast.Name) and n.id != 'self':
if qualifier_in == 'auto' and n.id == "Qt": # Qt namespace
qualifier = "::"
result = n.id + qualifier + result
return result
def format_reference(node, qualifier='auto'):
"""Format member reference or free item"""
return node.id if isinstance(node, ast.Name) else format_member(node, qualifier)
def format_function_def_arguments(function_def_node):
"""Formats arguments of a function definition"""
# Default values is a list of the last default values, expand
# so that indexes match
argument_count = len(function_def_node.args.args)
default_values = function_def_node.args.defaults
while len(default_values) < argument_count:
default_values.insert(0, None)
result = ''
for i, a in enumerate(function_def_node.args.args):
if result:
result += ', '
if a.arg != 'self':
if a.annotation and isinstance(a.annotation, ast.Name):
result += _fix_function_argument_type(a.annotation.id, False) + ' '
result += a.arg
if default_values[i]:
result += ' = '
default_value = default_values[i]
if isinstance(default_value, ast.Attribute):
result += format_reference(default_value)
else:
result += format_literal(default_value)
return result
def format_start_function_call(call_node):
"""Format a call of a free or member function"""
return format_reference(call_node.func) + '('
def write_import(file, i_node):
"""Print an import of a Qt class as #include"""
for alias in i_node.names:
if alias.name.startswith('Q'):
file.write(f'#include <{alias.name}>\n')
def write_import_from(file, i_node):
"""Print an import from Qt classes as #include sequence"""
# "from PySide6.QtGui import QGuiApplication" or
# "from PySide6 import QtGui"
mod = i_node.module
if not mod.startswith('PySide') and not mod.startswith('PyQt'):
return
dot = mod.find('.')
qt_module = mod[dot + 1:] + '/' if dot >= 0 else ''
for i in i_node.names:
if i.name.startswith('Q'):
file.write(f'#include <{qt_module}{i.name}>\n')
class Indenter:
"""Helper for Indentation"""
def __init__(self, output_file):
self._indent_level = 0
self._indentation = ''
self._output_file = output_file
def indent_string(self, string):
"""Start a new line by a string"""
self._output_file.write(self._indentation)
self._output_file.write(string)
def indent_line(self, line):
"""Write an indented line"""
self._output_file.write(self._indentation)
self._output_file.write(line)
self._output_file.write('\n')
def INDENT(self):
"""Write indentation"""
self._output_file.write(self._indentation)
def indent(self):
"""Increase indentation level"""
self._indent_level = self._indent_level + 1
self._indentation = ' ' * self._indent_level
def dedent(self):
"""Decrease indentation level"""
self._indent_level = self._indent_level - 1
self._indentation = ' ' * self._indent_level
class CppFormatter(Indenter):
"""Provides helpers for formatting multi-line C++ constructs"""
def __init__(self, output_file):
Indenter.__init__(self, output_file)
def write_class_def(self, class_node):
"""Print a class definition with inheritance"""
self._output_file.write('\n')
inherits = format_inheritance(class_node)
self.indent_line(f'class {class_node.name}{inherits}')
self.indent_line('{')
self.indent_line('public:')
def write_function_def(self, f_node, class_context):
"""Print a function definition with arguments"""
self._output_file.write('\n')
arguments = format_function_def_arguments(f_node)
if f_node.name == '__init__' and class_context: # Constructor
name = class_context
elif f_node.name == '__del__' and class_context: # Destructor
name = '~' + class_context
else:
return_type = "void"
if f_node.returns and isinstance(f_node.returns, ast.Name):
return_type = _fix_function_argument_type(f_node.returns.id, True)
name = return_type + " " + f_node.name
self.indent_string(f'{name}({arguments})')
self._output_file.write('\n')
self.indent_line('{')

View File

@@ -0,0 +1,51 @@
# 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
"""Helper to dump AST nodes for debugging"""
import ast
def to_string(node):
"""Helper to retrieve a string from the (Lists of )Name/Attribute
aggregated into some nodes"""
if isinstance(node, ast.Name):
return node.id
if isinstance(node, ast.Attribute):
return node.attr
return ''
def debug_format_node(node):
"""Format AST node for debugging"""
if isinstance(node, ast.alias):
return f'alias("{node.name}")'
if isinstance(node, ast.arg):
return f'arg({node.arg})'
if isinstance(node, ast.Attribute):
if isinstance(node.value, ast.Name):
nested_name = debug_format_node(node.value)
return f'Attribute("{node.attr}", {nested_name})'
return f'Attribute("{node.attr}")'
if isinstance(node, ast.Call):
return 'Call({}({}))'.format(to_string(node.func), len(node.args))
if isinstance(node, ast.ClassDef):
base_names = [to_string(base) for base in node.bases]
bases = ': ' + ','.join(base_names) if base_names else ''
return f'ClassDef({node.name}{bases})'
if isinstance(node, ast.ImportFrom):
return f'ImportFrom("{node.module}")'
if isinstance(node, ast.FunctionDef):
arg_names = [a.arg for a in node.args.args]
return 'FunctionDef({}({}))'.format(node.name, ', '.join(arg_names))
if isinstance(node, ast.Name):
return 'Name("{}", Ctx={})'.format(node.id, type(node.ctx).__name__)
if isinstance(node, ast.NameConstant):
return f'NameConstant({node.value})'
if isinstance(node, ast.Num):
return f'Num({node.n})'
if isinstance(node, ast.Str):
return f'Str("{node.s}")'
return type(node).__name__

View File

@@ -0,0 +1,63 @@
# Copyright (C) 2022 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR LGPL-3.0-only OR GPL-2.0-only OR GPL-3.0-only
from __future__ import annotations
"""Provides some type information on Qt classes"""
from enum import Flag
class ClassFlag(Flag):
PASS_BY_CONSTREF = 1
PASS_BY_REF = 2
PASS_BY_VALUE = 4
PASS_ON_STACK_MASK = PASS_BY_CONSTREF | PASS_BY_REF | PASS_BY_VALUE
INSTANTIATE_ON_STACK = 8
_QT_CLASS_FLAGS = {
# QtCore
"QCoreApplication": ClassFlag.INSTANTIATE_ON_STACK,
"QFile": ClassFlag.PASS_BY_REF | ClassFlag.INSTANTIATE_ON_STACK,
"QFileInfo": ClassFlag.INSTANTIATE_ON_STACK,
"QLine": ClassFlag.PASS_BY_CONSTREF | ClassFlag.INSTANTIATE_ON_STACK,
"QLineF": ClassFlag.PASS_BY_CONSTREF | ClassFlag.INSTANTIATE_ON_STACK,
"QModelIndex": ClassFlag.PASS_BY_CONSTREF | ClassFlag.INSTANTIATE_ON_STACK,
"QPoint": ClassFlag.PASS_BY_VALUE | ClassFlag.INSTANTIATE_ON_STACK,
"QPointF": ClassFlag.PASS_BY_CONSTREF | ClassFlag.INSTANTIATE_ON_STACK,
"QRect": ClassFlag.PASS_BY_CONSTREF | ClassFlag.INSTANTIATE_ON_STACK,
"QRectF": ClassFlag.PASS_BY_CONSTREF | ClassFlag.INSTANTIATE_ON_STACK,
"QSaveFile": ClassFlag.INSTANTIATE_ON_STACK,
"QSettings": ClassFlag.PASS_BY_REF | ClassFlag.INSTANTIATE_ON_STACK,
"QSize": ClassFlag.PASS_BY_VALUE | ClassFlag.INSTANTIATE_ON_STACK,
"QSizeF": ClassFlag.PASS_BY_CONSTREF | ClassFlag.INSTANTIATE_ON_STACK,
"QString": ClassFlag.PASS_BY_CONSTREF | ClassFlag.INSTANTIATE_ON_STACK,
"QTextStream": ClassFlag.PASS_BY_REF | ClassFlag.INSTANTIATE_ON_STACK,
# QtGui
"QBrush": ClassFlag.PASS_BY_CONSTREF | ClassFlag.INSTANTIATE_ON_STACK,
"QColor": ClassFlag.PASS_BY_VALUE | ClassFlag.INSTANTIATE_ON_STACK,
"QGradient": ClassFlag.PASS_BY_CONSTREF | ClassFlag.INSTANTIATE_ON_STACK,
"QGuiApplication": ClassFlag.INSTANTIATE_ON_STACK,
"QIcon": ClassFlag.PASS_BY_CONSTREF | ClassFlag.INSTANTIATE_ON_STACK,
"QPainter": ClassFlag.INSTANTIATE_ON_STACK,
"QPen": ClassFlag.INSTANTIATE_ON_STACK,
"QPixmap": ClassFlag.PASS_BY_CONSTREF | ClassFlag.INSTANTIATE_ON_STACK,
# QtWidgets
"QApplication": ClassFlag.INSTANTIATE_ON_STACK,
"QColorDialog": ClassFlag.INSTANTIATE_ON_STACK,
"QFileDialog": ClassFlag.INSTANTIATE_ON_STACK,
"QFontDialog": ClassFlag.INSTANTIATE_ON_STACK,
"QMessageBox": ClassFlag.INSTANTIATE_ON_STACK,
# QtQml
"QQmlApplicationEngine": ClassFlag.INSTANTIATE_ON_STACK,
"QQmlComponent": ClassFlag.INSTANTIATE_ON_STACK,
"QQmlEngine": ClassFlag.INSTANTIATE_ON_STACK,
# QtQuick
"QQuickView": ClassFlag.INSTANTIATE_ON_STACK
}
def qt_class_flags(type):
f = _QT_CLASS_FLAGS.get(type)
return f if f else ClassFlag(0)

View File

@@ -0,0 +1,56 @@
# Copyright (C) 2022 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR LGPL-3.0-only OR GPL-2.0-only OR GPL-3.0-only
from __future__ import annotations
"""Tool to dump Python Tokens"""
import sys
import tokenize
def format_token(t):
r = repr(t)
if r.startswith('TokenInfo('):
r = r[10:]
pos = r.find("), line='")
if pos < 0:
pos = r.find('), line="')
if pos > 0:
r = r[:pos + 1]
return r
def first_non_space(s):
for i, c in enumerate(s):
if c != ' ':
return i
return 0
if __name__ == '__main__':
if len(sys.argv) < 2:
print("Specify file Name")
sys.exit(1)
filename = sys.argv[1]
indent_level = 0
indent = ''
last_line_number = -1
with tokenize.open(filename) as f:
generator = tokenize.generate_tokens(f.readline)
for t in generator:
line_number = t.start[0]
if line_number != last_line_number:
code_line = t.line.rstrip()
non_space = first_non_space(code_line)
print('{:04d} {}{}'.format(line_number, '_' * non_space,
code_line[non_space:]))
last_line_number = line_number
if t.type == tokenize.INDENT:
indent_level = indent_level + 1
indent = ' ' * indent_level
elif t.type == tokenize.DEDENT:
indent_level = indent_level - 1
indent = ' ' * indent_level
else:
print(' ', indent, format_token(t))

View File

@@ -0,0 +1,443 @@
# 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
"""AST visitor printing out C++"""
import ast
import sys
import tokenize
import warnings
from .formatter import (CppFormatter, format_for_loop, format_literal,
format_name_constant,
format_reference, write_import, write_import_from)
from .nodedump import debug_format_node
from .qt import ClassFlag, qt_class_flags
def _is_qt_constructor(assign_node):
"""Is this assignment node a plain construction of a Qt class?
'f = QFile(name)'. Returns the class_name."""
call = assign_node.value
if (isinstance(call, ast.Call) and isinstance(call.func, ast.Name)):
func = call.func.id
if func.startswith("Q"):
return func
return None
def _is_if_main(if_node):
"""Return whether an if statement is: if __name__ == '__main__' """
test = if_node.test
return (isinstance(test, ast.Compare)
and len(test.ops) == 1
and isinstance(test.ops[0], ast.Eq)
and isinstance(test.left, ast.Name)
and test.left.id == "__name__"
and len(test.comparators) == 1
and isinstance(test.comparators[0], ast.Constant)
and test.comparators[0].value == "__main__")
class ConvertVisitor(ast.NodeVisitor, CppFormatter):
"""AST visitor printing out C++
Note on implementation:
- Any visit_XXX() overridden function should call self.generic_visit(node)
to continue visiting
- When controlling the visiting manually (cf visit_Call()),
self.visit(child) needs to be called since that dispatches to
visit_XXX(). This is usually done to prevent undesired output
for example from references of calls, etc.
"""
debug = False
def __init__(self, file_name, output_file):
ast.NodeVisitor.__init__(self)
CppFormatter.__init__(self, output_file)
self._file_name = file_name
self._class_scope = [] # List of class names
self._stack = [] # nodes
self._stack_variables = [] # variables instantiated on stack
self._debug_indent = 0
@staticmethod
def create_ast(filename):
"""Create an Abstract Syntax Tree on which a visitor can be run"""
node = None
with tokenize.open(filename) as file:
node = ast.parse(file.read(), mode="exec")
return node
def generic_visit(self, node):
parent = self._stack[-1] if self._stack else None
if self.debug:
self._debug_enter(node, parent)
self._stack.append(node)
try:
super().generic_visit(node)
except Exception as e:
line_no = node.lineno if hasattr(node, 'lineno') else -1
error_message = str(e)
message = f'{self._file_name}:{line_no}: Error "{error_message}"'
warnings.warn(message)
self._output_file.write(f'\n// {error_message}\n')
del self._stack[-1]
if self.debug:
self._debug_leave(node)
def visit_Add(self, node):
self._handle_bin_op(node, "+")
def _is_augmented_assign(self):
"""Is it 'Augmented_assign' (operators +=/-=, etc)?"""
return self._stack and isinstance(self._stack[-1], ast.AugAssign)
def visit_AugAssign(self, node):
"""'Augmented_assign', Operators +=/-=, etc."""
self.INDENT()
self.generic_visit(node)
self._output_file.write("\n")
def visit_Assign(self, node):
self.INDENT()
qt_class = _is_qt_constructor(node)
on_stack = qt_class and qt_class_flags(qt_class) & ClassFlag.INSTANTIATE_ON_STACK
# Is this a free variable and not a member assignment? Instantiate
# on stack or give a type
if len(node.targets) == 1 and isinstance(node.targets[0], ast.Name):
if qt_class:
if on_stack:
# "QFile f(args)"
var = node.targets[0].id
self._stack_variables.append(var)
self._output_file.write(f"{qt_class} {var}(")
self._write_function_args(node.value.args)
self._output_file.write(");\n")
return
self._output_file.write("auto *")
line_no = node.lineno if hasattr(node, 'lineno') else -1
for target in node.targets:
if isinstance(target, ast.Tuple):
w = f"{self._file_name}:{line_no}: List assignment not handled."
warnings.warn(w)
elif isinstance(target, ast.Subscript):
w = f"{self._file_name}:{line_no}: Subscript assignment not handled."
warnings.warn(w)
else:
self._output_file.write(format_reference(target))
self._output_file.write(' = ')
if qt_class and not on_stack:
self._output_file.write("new ")
self.visit(node.value)
self._output_file.write(';\n')
def visit_Attribute(self, node):
"""Format a variable reference (cf visit_Name)"""
# Default parameter (like Qt::black)?
if self._ignore_function_def_node(node):
return
self._output_file.write(format_reference(node))
def visit_BinOp(self, node):
# Parentheses are not exposed, so, every binary operation needs to
# be enclosed by ().
self._output_file.write('(')
self.generic_visit(node)
self._output_file.write(')')
def _handle_bin_op(self, node, op):
"""Handle a binary operator which can appear as 'Augmented Assign'."""
self.generic_visit(node)
full_op = f" {op}= " if self._is_augmented_assign() else f" {op} "
self._output_file.write(full_op)
def visit_BitAnd(self, node):
self._handle_bin_op(node, "&")
def visit_BitOr(self, node):
self._handle_bin_op(node, "|")
def _format_call(self, node):
# Decorator list?
if self._ignore_function_def_node(node):
return
f = node.func
if isinstance(f, ast.Name):
self._output_file.write(f.id)
else:
# Attributes denoting chained calls "a->b()->c()". Walk along in
# reverse order, recursing for other calls.
names = []
n = f
while isinstance(n, ast.Attribute):
names.insert(0, n.attr)
n = n.value
if isinstance(n, ast.Name): # Member or variable reference
if n.id != "self":
sep = "->"
if n.id in self._stack_variables:
sep = "."
elif n.id[0:1].isupper(): # Heuristics for static
sep = "::"
self._output_file.write(n.id)
self._output_file.write(sep)
elif isinstance(n, ast.Call): # A preceding call
self._format_call(n)
self._output_file.write("->")
self._output_file.write("->".join(names))
self._output_file.write('(')
self._write_function_args(node.args)
self._output_file.write(')')
def visit_Call(self, node):
self._format_call(node)
# Context manager expression?
if self._within_context_manager():
self._output_file.write(";\n")
def _write_function_args(self, args_node):
# Manually do visit(), skip the children of func
for i, arg in enumerate(args_node):
if i > 0:
self._output_file.write(', ')
self.visit(arg)
def visit_ClassDef(self, node):
# Manually do visit() to skip over base classes
# and annotations
self._class_scope.append(node.name)
self.write_class_def(node)
self.indent()
for b in node.body:
self.visit(b)
self.dedent()
self.indent_line('};')
del self._class_scope[-1]
def visit_Div(self, node):
self._handle_bin_op(node, "/")
def visit_Eq(self, node):
self.generic_visit(node)
self._output_file.write(" == ")
def visit_Expr(self, node):
self.INDENT()
self.generic_visit(node)
self._output_file.write(';\n')
def visit_Gt(self, node):
self.generic_visit(node)
self._output_file.write(" > ")
def visit_GtE(self, node):
self.generic_visit(node)
self._output_file.write(" >= ")
def visit_For(self, node):
# Manually do visit() to get the indentation right.
# TODO: what about orelse?
self.indent_line(format_for_loop(node))
self.indent()
for b in node.body:
self.visit(b)
self.dedent()
self.indent_line('}')
def visit_FunctionDef(self, node):
class_context = self._class_scope[-1] if self._class_scope else None
for decorator in node.decorator_list:
func = decorator.func # (Call)
if isinstance(func, ast.Name) and func.id == "Slot":
self._output_file.write("\npublic slots:")
self.write_function_def(node, class_context)
# Find stack variables
for arg in node.args.args:
if arg.annotation and isinstance(arg.annotation, ast.Name):
type_name = arg.annotation.id
flags = qt_class_flags(type_name)
if flags & ClassFlag.PASS_ON_STACK_MASK:
self._stack_variables.append(arg.arg)
self.indent()
self.generic_visit(node)
self.dedent()
self.indent_line('}')
self._stack_variables.clear()
def visit_If(self, node):
# Manually do visit() to get the indentation right. Note:
# elsif() is modelled as nested if.
# Check for the main function
if _is_if_main(node):
self._output_file.write("\nint main(int argc, char *argv[])\n{\n")
self.indent()
for b in node.body:
self.visit(b)
self.indent_string("return 0;\n")
self.dedent()
self._output_file.write("}\n")
return
self.indent_string('if (')
self.visit(node.test)
self._output_file.write(') {\n')
self.indent()
for b in node.body:
self.visit(b)
self.dedent()
self.indent_string('}')
if node.orelse:
self._output_file.write(' else {\n')
self.indent()
for b in node.orelse:
self.visit(b)
self.dedent()
self.indent_string('}')
self._output_file.write('\n')
def visit_Import(self, node):
write_import(self._output_file, node)
def visit_ImportFrom(self, node):
write_import_from(self._output_file, node)
def visit_List(self, node):
# Manually do visit() to get separators right
self._output_file.write('{')
for i, el in enumerate(node.elts):
if i > 0:
self._output_file.write(', ')
self.visit(el)
self._output_file.write('}')
def visit_LShift(self, node):
self.generic_visit(node)
self._output_file.write(" << ")
def visit_Lt(self, node):
self.generic_visit(node)
self._output_file.write(" < ")
def visit_LtE(self, node):
self.generic_visit(node)
self._output_file.write(" <= ")
def visit_Mult(self, node):
self._handle_bin_op(node, "*")
def _within_context_manager(self):
"""Return whether we are within a context manager (with)."""
parent = self._stack[-1] if self._stack else None
return parent and isinstance(parent, ast.withitem)
def _ignore_function_def_node(self, node):
"""Should this node be ignored within a FunctionDef."""
if not self._stack:
return False
parent = self._stack[-1]
# A type annotation or default value of an argument?
if isinstance(parent, (ast.arguments, ast.arg)):
return True
if not isinstance(parent, ast.FunctionDef):
return False
# Return type annotation or decorator call
return node == parent.returns or node in parent.decorator_list
def visit_Index(self, node):
self._output_file.write("[")
self.generic_visit(node)
self._output_file.write("]")
def visit_Name(self, node):
"""Format a variable reference (cf visit_Attribute)"""
# Skip Context manager variables, return or argument type annotation
if self._within_context_manager() or self._ignore_function_def_node(node):
return
self._output_file.write(format_reference(node))
def visit_NameConstant(self, node):
# Default parameter?
if self._ignore_function_def_node(node):
return
self.generic_visit(node)
self._output_file.write(format_name_constant(node))
def visit_Not(self, node):
self.generic_visit(node)
self._output_file.write("!")
def visit_NotEq(self, node):
self.generic_visit(node)
self._output_file.write(" != ")
def visit_Num(self, node):
self.generic_visit(node)
self._output_file.write(format_literal(node))
def visit_RShift(self, node):
self.generic_visit(node)
self._output_file.write(" >> ")
def visit_Return(self, node):
self.indent_string("return")
if node.value:
self._output_file.write(" ")
self.generic_visit(node)
self._output_file.write(";\n")
def visit_Slice(self, node):
self._output_file.write("[")
if node.lower:
self.visit(node.lower)
self._output_file.write(":")
if node.upper:
self.visit(node.upper)
self._output_file.write("]")
def visit_Str(self, node):
self.generic_visit(node)
self._output_file.write(format_literal(node))
def visit_Sub(self, node):
self._handle_bin_op(node, "-")
def visit_UnOp(self, node):
self.generic_visit(node)
def visit_With(self, node):
self.INDENT()
self._output_file.write("{ // Converted from context manager\n")
self.indent()
for item in node.items:
self.INDENT()
if item.optional_vars:
self._output_file.write(format_reference(item.optional_vars))
self._output_file.write(" = ")
self.generic_visit(node)
self.dedent()
self.INDENT()
self._output_file.write("}\n")
def _debug_enter(self, node, parent=None):
message = '{}>generic_visit({})'.format(' ' * self ._debug_indent,
debug_format_node(node))
if parent:
message += ', parent={}'.format(debug_format_node(parent))
message += '\n'
sys.stderr.write(message)
self._debug_indent += 1
def _debug_leave(self, node):
self._debug_indent -= 1
message = '{}<generic_visit({})\n'.format(' ' * self ._debug_indent,
type(node).__name__)
sys.stderr.write(message)