First upload, 18 controller version
This commit is contained in:
@@ -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
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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")
|
||||
Reference in New Issue
Block a user