First upload, 18 controller version

This commit is contained in:
2026-04-14 15:23:56 +02:00
commit 8c55001a1c
3810 changed files with 764061 additions and 0 deletions

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")