diff options
Diffstat (limited to 'sources/pyside-tools/project')
-rw-r--r-- | sources/pyside-tools/project/__init__.py | 47 | ||||
-rw-r--r-- | sources/pyside-tools/project/newproject.py | 165 | ||||
-rw-r--r-- | sources/pyside-tools/project/project_data.py | 244 | ||||
-rw-r--r-- | sources/pyside-tools/project/utils.py | 107 |
4 files changed, 563 insertions, 0 deletions
diff --git a/sources/pyside-tools/project/__init__.py b/sources/pyside-tools/project/__init__.py new file mode 100644 index 000000000..926a1fc9a --- /dev/null +++ b/sources/pyside-tools/project/__init__.py @@ -0,0 +1,47 @@ +# 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" + +PROJECT_FILE_SUFFIX = ".pyproject" +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_project_file) +from .project_data import (is_python_file, ProjectData, QmlProjectData, + check_qml_decorators) +from .newproject import new_project, ProjectType diff --git a/sources/pyside-tools/project/newproject.py b/sources/pyside-tools/project/newproject.py new file mode 100644 index 000000000..80ed5a75e --- /dev/null +++ b/sources/pyside-tools/project/newproject.py @@ -0,0 +1,165 @@ +# 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 sys +from enum import Enum +from pathlib import Path + +"""New project generation code.""" + + +Project = list[tuple[str, str]] # tuple of (filename, contents). + + +class ProjectType(Enum): + WIDGET_FORM = 1 + WIDGET = 2 + QUICK = 3 + + +_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) +""" + + +def _write_project(directory: Path, files: Project): + """Write out the project.""" + file_list = [] + for file, contents in files: + (directory / file).write_text(contents) + print(f"Wrote {directory.name}{os.sep}{file}.") + file_list.append(file) + pyproject = {"files": file_list} + pyproject_file = f"{directory}.pyproject" + (directory / pyproject_file).write_text(json.dumps(pyproject)) + print(f"Wrote {directory.name}{os.sep}{pyproject_file}.") + + +def _widget_project() -> Project: + """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() -> Project: + """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() -> Project: + """Create a QML project.""" + return [("main.py", _QUICK_MAIN), + ("main.qml", _QUICK_FORM)] + + +def new_project(directory_s: str, + project_type: ProjectType = ProjectType.WIDGET_FORM) -> int: + directory = Path(directory_s) + if directory.exists(): + print(f"{directory_s} already exists.", file=sys.stderr) + return -1 + directory.mkdir(parents=True) + + if project_type == ProjectType.WIDGET_FORM: + project = _ui_form_project() + elif project_type == ProjectType.QUICK: + project = _qml_project() + else: + project = _widget_project() + _write_project(directory, project) + if project_type == ProjectType.WIDGET_FORM: + print(f'Run "pyside6-project build {directory_s}" to build the project') + print(f'Run "python {directory.name}{os.sep}main.py" to run the project') + return 0 diff --git a/sources/pyside-tools/project/project_data.py b/sources/pyside-tools/project/project_data.py new file mode 100644 index 000000000..20a8daef1 --- /dev/null +++ b/sources/pyside-tools/project/project_data.py @@ -0,0 +1,244 @@ +# 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, PROJECT_FILE_SUFFIX, TRANSLATION_SUFFIX, + qt_metatype_json_dir, MOD_CMD, QML_IMPORT_MAJOR_VERSION, + QML_IMPORT_MINOR_VERSION, QML_IMPORT_NAME, QT_MODULES) + + +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.""" + self._project_file = project_file + 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] = [] + + with project_file.open("r") as pyf: + pyproject = json.load(pyf) + for f in pyproject["files"]: + file = Path(project_file.parent / f) + if file.suffix == PROJECT_FILE_SUFFIX: + self._sub_projects_files.append(file) + else: + 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( + "Python file with main function not found. Add the file to" f" {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) diff --git a/sources/pyside-tools/project/utils.py b/sources/pyside-tools/project/utils.py new file mode 100644 index 000000000..a2d91375b --- /dev/null +++ b/sources/pyside-tools/project/utils.py @@ -0,0 +1,107 @@ +# 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 subprocess +from pathlib import Path + +from . import QTPATHS_CMD, PROJECT_FILE_SUFFIX, ClOptions + + +def run_command(command: list[str], cwd: str = None, ignore_fail: bool = False): + """Run a command observing quiet/dry run""" + cloptions = ClOptions() + if not cloptions.quiet or cloptions.dry_run: + print(" ".join(command)) + if not cloptions.dry_run: + ex = subprocess.call(command, cwd=cwd) + if ex != 0 and not ignore_fail: + sys.exit(ex) + + +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 + 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_project_file(cmdline: str) -> Path | None: + """Return the project file from the command line value, either + from the file argument or directory""" + project_file = Path(cmdline).resolve() if cmdline else Path.cwd() + if project_file.is_file(): + return project_file + if project_file.is_dir(): + for m in project_file.glob(f"*{PROJECT_FILE_SUFFIX}"): + return m + return None |