Location via proxy:   [ UP ]  
[Report a bug]   [Manage cookies]                
aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
Diffstat (limited to 'sources/pyside-tools/project.py')
-rw-r--r--sources/pyside-tools/project.py526
1 files changed, 526 insertions, 0 deletions
diff --git a/sources/pyside-tools/project.py b/sources/pyside-tools/project.py
new file mode 100644
index 000000000..bc13c2bc2
--- /dev/null
+++ b/sources/pyside-tools/project.py
@@ -0,0 +1,526 @@
+#############################################################################
+##
+## Copyright (C) 2022 The Qt Company Ltd.
+## Contact: https://www.qt.io/licensing/
+##
+## This file is part of Qt for Python.
+##
+## $QT_BEGIN_LICENSE:LGPL$
+## Commercial License Usage
+## Licensees holding valid commercial Qt licenses may use this file in
+## accordance with the commercial license agreement provided with the
+## Software or, alternatively, in accordance with the terms contained in
+## a written agreement between you and The Qt Company. For licensing terms
+## and conditions see https://www.qt.io/terms-conditions. For further
+## information use the contact form at https://www.qt.io/contact-us.
+##
+## GNU Lesser General Public License Usage
+## Alternatively, this file may be used under the terms of the GNU Lesser
+## General Public License version 3 as published by the Free Software
+## Foundation and appearing in the file LICENSE.LGPL3 included in the
+## packaging of this file. Please review the following information to
+## ensure the GNU Lesser General Public License version 3 requirements
+## will be met: https://www.gnu.org/licenses/lgpl-3.0.html.
+##
+## GNU General Public License Usage
+## Alternatively, this file may be used under the terms of the GNU
+## General Public License version 2.0 or (at your option) the GNU General
+## Public license version 3 or any later version approved by the KDE Free
+## Qt Foundation. The licenses are as published by the Free Software
+## Foundation and appearing in the file LICENSE.GPL2 and LICENSE.GPL3
+## included in the packaging of this file. Please review the following
+## information to ensure the GNU General Public License requirements will
+## be met: https://www.gnu.org/licenses/gpl-2.0.html and
+## https://www.gnu.org/licenses/gpl-3.0.html.
+##
+## $QT_END_LICENSE$
+##
+#############################################################################
+
+
+"""
+Builds a '.pyproject' file
+
+Builds Qt Designer forms, resource files and QML type files.
+
+For each entry in a '.pyproject' file:
+- <name>.pyproject: Recurse to handle subproject
+- <name>.qrc : Runs the resource compiler to create a file rc_<name>.py
+- <name>.ui : Runs the user interface compiler to create a file ui_<name>.py
+
+For a Python file declaring a QML module, a directory matching the URI is
+created and populated with .qmltypes and qmldir files for use by code analysis
+tools. Currently, only one QML module consisting of several classes can be
+handled per project file.
+"""
+
+import json
+import os
+import subprocess
+import sys
+
+from argparse import ArgumentParser, RawTextHelpFormatter
+from pathlib import Path
+from typing import Dict, List, Optional, Tuple
+
+
+MODE_HELP = """build Builds the project
+run Builds the project and runs the first file")
+clean Cleans the build artifacts")
+qmllint Runs the qmllint tool"""
+
+
+opt_quiet = False
+opt_dry_run = False
+opt_force = False
+opt_qml_module = False
+
+
+UIC_CMD = "pyside6-uic"
+RCC_CMD = "pyside6-rcc"
+MOD_CMD = "pyside6-metaobjectdump"
+QMLTYPEREGISTRAR_CMD = "pyside6-qmltyperegistrar"
+QMLLINT_CMD = "pyside6-qmllint"
+QTPATHS_CMD = "qtpaths6"
+
+
+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"
+
+
+def run_command(command: List[str], cwd: str = None, ignore_fail: bool = False):
+ """Run a command observing quiet/dry run"""
+ if not opt_quiet or opt_dry_run:
+ print(" ".join(command))
+ if not opt_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."""
+ if not path.exists():
+ return
+ if not opt_quiet:
+ print(f"Removing {path.name}...")
+ if opt_dry_run:
+ return
+ _remove_path_recursion(path)
+
+
+def package_dir() -> Path:
+ """Return the PySide6 root."""
+ return Path(__file__).resolve().parents[1]
+
+
+_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(":")
+ if len(tokens) == 2:
+ _qtpaths_info[tokens[0]] = tokens[1]
+ return _qtpaths_info
+
+
+_qt_metatype_json_dir: Optional[Path] = 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 / "lib" / "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_LIBS"]) / "metatypes"
+ return _qt_metatype_json_dir
+
+
+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}_*{METATYPES_JSON_SUFFIX}"
+ for f in meta_dir.glob(pattern):
+ foreign_files.append(os.fspath(f))
+ break
+ list = ",".join(foreign_files)
+ result.append(f"--foreign-types={list}")
+ 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)
+
+
+class Project:
+
+ def __init__(self, project_file: Path):
+ """Parse the project."""
+ self._project_file = project_file
+
+ # All sources except subprojects
+ self._files: List[Path] = []
+ # QML files
+ self._qml_files: List[Path] = []
+ self._sub_projects: List[Project] = []
+
+ # Files for QML modules using the QmlElement decorators
+ self._qml_module_sources: List[Path] = []
+ self._qml_module_dir: Optional[Path] = None
+ self._qml_dir_file: Optional[Path] = None
+ self._qml_project_data = QmlProjectData()
+
+ 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.append(Project(file))
+ else:
+ self._files.append(file)
+ if file.suffix == ".qml":
+ self._qml_files.append(file)
+ self._qml_module_check()
+
+ @property
+ def project_file(self):
+ return self._project_file
+
+ @property
+ def files(self):
+ return self._files
+
+ 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 opt_qml_module and not self._qml_files:
+ return
+ for file in self.files:
+ if file.suffix == ".py":
+ 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_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 opt_quiet:
+ count = len(self._qml_module_sources)
+ print(f"{self._project_file.name}, {count} QML file(s), {self._qml_project_data}")
+
+ def _get_artifact(self, file: Path) -> Tuple[Optional[Path], Optional[List[str]]]:
+ """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), "-o", py_file])
+ if file.suffix == ".qrc": # Qt resources
+ py_file = f"{file.parent}/rc_{file.stem}.py"
+ 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"
+ cmd = [QMLTYPEREGISTRAR_CMD, "--generate-qmltypes",
+ os.fspath(qmltypes_file), "-o", os.devnull, os.fspath(file)]
+ cmd.extend(self._qml_project_data.registrar_options())
+ return (qmltypes_file, cmd)
+
+ return (None, None)
+
+ def _regenerate_qmldir(self):
+ """Regenerate the 'qmldir' file."""
+ if opt_dry_run or not self._qml_dir_file:
+ return
+ if opt_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):
+ """Build an artifact."""
+ artifact, command = self._get_artifact(source)
+ if not artifact:
+ return
+ if opt_force or requires_rebuild([source], artifact):
+ run_command(command, cwd=self._project_file.parent)
+ self._build_file(artifact) # Recurse for QML (json->qmltypes)
+
+ def build(self):
+ """Build."""
+ for sub_project in self._sub_projects:
+ sub_project.build()
+ if self._qml_module_dir:
+ self._qml_module_dir.mkdir(exist_ok=True, parents=True)
+ for file in self._files:
+ self._build_file(file)
+ self._regenerate_qmldir()
+
+ def run(self):
+ """Runs the project (first .py file)."""
+ self.build()
+ if self.files:
+ for file in self._files:
+ if file.suffix == ".py":
+ cmd = [sys.executable, os.fspath(file)]
+ run_command(cmd, cwd=self._project_file.parent)
+ break
+
+ def _clean_file(self, source: Path):
+ """Clean an artifact."""
+ artifact, command = self._get_artifact(source)
+ if artifact and artifact.is_file():
+ remove_path(artifact)
+ self._clean_file(artifact) # Recurse for QML (json->qmltypes)
+
+ def clean(self):
+ """Clean build artifacts."""
+ for sub_project in self._sub_projects:
+ sub_project.clean()
+ for file in self._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_file.parent:
+ project_dir_parts = len(self._project_file.parent.parts)
+ first_module_dir = self._qml_module_dir.parts[project_dir_parts]
+ remove_path(self._project_file.parent / first_module_dir)
+
+ def _qmllint(self):
+ """Helper for running qmllint on .qml files (non-recursive)."""
+ if not self._qml_files:
+ print(f"{self._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._qml_files:
+ cmd.append(os.fspath(f))
+ run_command(cmd, cwd=self._project_file.parent, ignore_fail=True)
+
+ def qmllint(self):
+ """Run qmllint on .qml files."""
+ self.build()
+ for sub_project in self._sub_projects:
+ sub_project._qmllint()
+ self._qmllint()
+
+
+def resolve_project_file(cmdline: str) -> Optional[Path]:
+ """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
+
+
+if __name__ == "__main__":
+ parser = ArgumentParser(description=__doc__,
+ 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")
+ parser.add_argument("mode",
+ choices=["build", "run", "clean", "qmllint"],
+ default="build", type=str, help=MODE_HELP)
+ parser.add_argument("file", help="Project file", nargs="?", type=str)
+
+ options = parser.parse_args()
+ opt_quiet = options.quiet
+ opt_dry_run = options.dry_run
+ opt_force = options.force
+ opt_qml_module = options.qml_module
+ mode = options.mode
+
+ project_file = resolve_project_file(options.file)
+ if not project_file:
+ print(f"Cannot determine project_file {options.file}", file=sys.stderr)
+ sys.exit(1)
+ project = Project(project_file)
+ if mode == "build":
+ project.build()
+ elif mode == "run":
+ project.run()
+ elif mode == "clean":
+ project.clean()
+ elif mode == "qmllint":
+ project.qmllint()
+ else:
+ print(f"Invalid mode {mode}", file=sys.stderr)
+ sys.exit(1)