project.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348
  1. # Copyright (C) 2022 The Qt Company Ltd.
  2. # SPDX-License-Identifier: LicenseRef-Qt-Commercial OR LGPL-3.0-only OR GPL-2.0-only OR GPL-3.0-only
  3. from __future__ import annotations
  4. import sys
  5. import os
  6. from pathlib import Path
  7. from argparse import ArgumentParser, RawTextHelpFormatter
  8. from project_lib import (QmlProjectData, check_qml_decorators, is_python_file, migrate_pyproject,
  9. QMLDIR_FILE, MOD_CMD, METATYPES_JSON_SUFFIX, SHADER_SUFFIXES,
  10. TRANSLATION_SUFFIX, requires_rebuild, run_command, remove_path,
  11. ProjectData, resolve_valid_project_file, new_project, NewProjectTypes,
  12. ClOptions, DesignStudioProject)
  13. DESCRIPTION = """
  14. pyside6-project is a command line tool for creating, building and deploying Qt for Python
  15. applications. It operates on project files which are also used by Qt Creator.
  16. Official documentation:
  17. https://doc.qt.io/qtforpython-6/tools/pyside-project.html
  18. """
  19. OPERATION_HELP = {
  20. "build": "Build the project. Compiles resources, UI files, and QML files if existing and "
  21. "necessary.",
  22. "run": "Build and run the project.",
  23. "clean": "Clean build artifacts and generated files from the project directory.",
  24. "qmllint": "Run the qmllint tool on QML files in the project.",
  25. "deploy": "Create a deployable package of the application including all dependencies.",
  26. "lupdate": "Update translation files (.ts) with new strings from source files.",
  27. "migrate-pyproject": "Migrate a *.pyproject file to pyproject.toml format."
  28. }
  29. UIC_CMD = "pyside6-uic"
  30. RCC_CMD = "pyside6-rcc"
  31. LRELEASE_CMD = "pyside6-lrelease"
  32. LUPDATE_CMD = "pyside6-lupdate"
  33. QMLTYPEREGISTRAR_CMD = "pyside6-qmltyperegistrar"
  34. QMLLINT_CMD = "pyside6-qmllint"
  35. QSB_CMD = "pyside6-qsb"
  36. DEPLOY_CMD = "pyside6-deploy"
  37. def _sort_sources(files: list[Path]) -> list[Path]:
  38. """Sort the sources for building, ensure .qrc is last since it might depend
  39. on generated files."""
  40. def key_func(p: Path):
  41. return p.suffix if p.suffix != ".qrc" else ".zzzz"
  42. return sorted(files, key=key_func)
  43. class Project:
  44. """
  45. Class to wrap the various operations on Project
  46. """
  47. def __init__(self, project_file: Path):
  48. self.project = ProjectData(project_file=project_file)
  49. self.cl_options = ClOptions()
  50. # Files for QML modules using the QmlElement decorators
  51. self._qml_module_sources: list[Path] = []
  52. self._qml_module_dir: Path | None = None
  53. self._qml_dir_file: Path | None = None
  54. self._qml_project_data = QmlProjectData()
  55. self._qml_module_check()
  56. def _qml_module_check(self):
  57. """Run a pre-check on Python source files and find the ones with QML
  58. decorators (representing a QML module)."""
  59. # Quick check for any QML files (to avoid running moc for no reason).
  60. if not self.cl_options.qml_module and not self.project.qml_files:
  61. return
  62. for file in self.project.files:
  63. if is_python_file(file):
  64. has_class, data = check_qml_decorators(file)
  65. if has_class:
  66. self._qml_module_sources.append(file)
  67. if data:
  68. self._qml_project_data = data
  69. if not self._qml_module_sources:
  70. return
  71. if not self._qml_project_data:
  72. print("Detected QML-decorated files, " "but was unable to detect QML_IMPORT_NAME")
  73. sys.exit(1)
  74. self._qml_module_dir = self.project.project_file.parent
  75. for uri_dir in self._qml_project_data.import_name.split("."):
  76. self._qml_module_dir /= uri_dir
  77. print(self._qml_module_dir)
  78. self._qml_dir_file = self._qml_module_dir / QMLDIR_FILE
  79. if not self.cl_options.quiet:
  80. count = len(self._qml_module_sources)
  81. print(f"{self.project.project_file.name}, {count} QML file(s),"
  82. f" {self._qml_project_data}")
  83. def _get_artifacts(self, file: Path, output_path: Path | None = None) -> \
  84. tuple[list[Path], list[str] | None]:
  85. """Return path and command for a file's artifact"""
  86. if file.suffix == ".ui": # Qt form files
  87. py_file = f"{file.parent}/ui_{file.stem}.py"
  88. return [Path(py_file)], [UIC_CMD, os.fspath(file), "--rc-prefix", "-o", py_file]
  89. if file.suffix == ".qrc": # Qt resources
  90. if not output_path:
  91. py_file = f"{file.parent}/rc_{file.stem}.py"
  92. else:
  93. py_file = str(output_path.resolve())
  94. return [Path(py_file)], [RCC_CMD, os.fspath(file), "-o", py_file]
  95. # generate .qmltypes from sources with Qml decorators
  96. if file.suffix == ".py" and file in self._qml_module_sources:
  97. assert self._qml_module_dir
  98. qml_module_dir = os.fspath(self._qml_module_dir)
  99. json_file = f"{qml_module_dir}/{file.stem}{METATYPES_JSON_SUFFIX}"
  100. return [Path(json_file)], [MOD_CMD, "-o", json_file, os.fspath(file)]
  101. # Run qmltyperegistrar
  102. if file.name.endswith(METATYPES_JSON_SUFFIX):
  103. assert self._qml_module_dir
  104. stem = file.name[: len(file.name) - len(METATYPES_JSON_SUFFIX)]
  105. qmltypes_file = self._qml_module_dir / f"{stem}.qmltypes"
  106. cpp_file = self._qml_module_dir / f"{stem}_qmltyperegistrations.cpp"
  107. cmd = [QMLTYPEREGISTRAR_CMD, "--generate-qmltypes",
  108. os.fspath(qmltypes_file), "-o", os.fspath(cpp_file),
  109. os.fspath(file)]
  110. cmd.extend(self._qml_project_data.registrar_options())
  111. return [qmltypes_file, cpp_file], cmd
  112. if file.name.endswith(TRANSLATION_SUFFIX):
  113. qm_file = f"{file.parent}/{file.stem}.qm"
  114. cmd = [LRELEASE_CMD, os.fspath(file), "-qm", qm_file]
  115. return [Path(qm_file)], cmd
  116. if file.suffix in SHADER_SUFFIXES:
  117. qsb_file = f"{file.parent}/{file.stem}.qsb"
  118. cmd = [QSB_CMD, "-o", qsb_file, os.fspath(file)]
  119. return [Path(qsb_file)], cmd
  120. return [], None
  121. def _regenerate_qmldir(self):
  122. """Regenerate the 'qmldir' file."""
  123. if self.cl_options.dry_run or not self._qml_dir_file:
  124. return
  125. if self.cl_options.force or requires_rebuild(self._qml_module_sources, self._qml_dir_file):
  126. with self._qml_dir_file.open("w") as qf:
  127. qf.write(f"module {self._qml_project_data.import_name}\n")
  128. for f in self._qml_module_dir.glob("*.qmltypes"):
  129. qf.write(f"typeinfo {f.name}\n")
  130. def _build_file(self, source: Path, output_path: Path | None = None):
  131. """Build an artifact if necessary."""
  132. artifacts, command = self._get_artifacts(source, output_path)
  133. for artifact in artifacts:
  134. if self.cl_options.force or requires_rebuild([source], artifact):
  135. run_command(command, cwd=self.project.project_file.parent)
  136. self._build_file(artifact) # Recurse for QML (json->qmltypes)
  137. def build_design_studio_resources(self):
  138. """
  139. The resources that need to be compiled are defined in autogen/settings.py
  140. """
  141. ds_project = DesignStudioProject(self.project.main_file)
  142. if (resources_file_path := ds_project.get_resource_file_path()) is None:
  143. return
  144. compiled_resources_file_path = ds_project.get_compiled_resources_file_path()
  145. self._build_file(resources_file_path, compiled_resources_file_path)
  146. def build(self):
  147. """Build the whole project"""
  148. for sub_project_file in self.project.sub_projects_files:
  149. Project(project_file=sub_project_file).build()
  150. if self._qml_module_dir:
  151. self._qml_module_dir.mkdir(exist_ok=True, parents=True)
  152. for file in _sort_sources(self.project.files):
  153. self._build_file(file)
  154. if DesignStudioProject.is_ds_project(self.project.main_file):
  155. self.build_design_studio_resources()
  156. self._regenerate_qmldir()
  157. def run(self) -> int:
  158. """Runs the project"""
  159. self.build()
  160. cmd = [sys.executable, str(self.project.main_file)]
  161. return run_command(cmd, cwd=self.project.project_file.parent)
  162. def _clean_file(self, source: Path):
  163. """Clean an artifact."""
  164. artifacts, command = self._get_artifacts(source)
  165. for artifact in artifacts:
  166. remove_path(artifact)
  167. self._clean_file(artifact) # Recurse for QML (json->qmltypes)
  168. def clean(self):
  169. """Clean build artifacts."""
  170. for sub_project_file in self.project.sub_projects_files:
  171. Project(project_file=sub_project_file).clean()
  172. for file in self.project.files:
  173. self._clean_file(file)
  174. if self._qml_module_dir and self._qml_module_dir.is_dir():
  175. remove_path(self._qml_module_dir)
  176. # In case of a dir hierarchy ("a.b" -> a/b), determine and delete
  177. # the root directory
  178. if self._qml_module_dir.parent != self.project.project_file.parent:
  179. project_dir_parts = len(self.project.project_file.parent.parts)
  180. first_module_dir = self._qml_module_dir.parts[project_dir_parts]
  181. remove_path(self.project.project_file.parent / first_module_dir)
  182. if DesignStudioProject.is_ds_project(self.project.main_file):
  183. DesignStudioProject(self.project.main_file).clean()
  184. def _qmllint(self):
  185. """Helper for running qmllint on .qml files (non-recursive)."""
  186. if not self.project.qml_files:
  187. print(f"{self.project.project_file.name}: No QML files found", file=sys.stderr)
  188. return
  189. cmd = [QMLLINT_CMD]
  190. if self._qml_dir_file:
  191. cmd.extend(["-i", os.fspath(self._qml_dir_file)])
  192. for f in self.project.qml_files:
  193. cmd.append(os.fspath(f))
  194. run_command(cmd, cwd=self.project.project_file.parent, ignore_fail=True)
  195. def qmllint(self):
  196. """Run qmllint on .qml files."""
  197. self.build()
  198. for sub_project_file in self.project.sub_projects_files:
  199. Project(project_file=sub_project_file)._qmllint()
  200. self._qmllint()
  201. def deploy(self):
  202. """Deploys the application"""
  203. cmd = [DEPLOY_CMD]
  204. cmd.extend([str(self.project.main_file), "-f"])
  205. run_command(cmd, cwd=self.project.project_file.parent)
  206. def lupdate(self):
  207. for sub_project_file in self.project.sub_projects_files:
  208. Project(project_file=sub_project_file).lupdate()
  209. if not self.project.ts_files:
  210. print(f"{self.project.project_file.name}: No .ts file found.",
  211. file=sys.stderr)
  212. return
  213. source_files = self.project.python_files + self.project.ui_files
  214. project_dir = self.project.project_file.parent
  215. cmd_prefix = [LUPDATE_CMD] + [os.fspath(p.relative_to(project_dir)) for p in source_files]
  216. cmd_prefix.append("-ts")
  217. for ts_file in self.project.ts_files:
  218. ts_dir = ts_file.parent
  219. if not ts_dir.exists():
  220. ts_dir.mkdir(parents=True, exist_ok=True)
  221. if requires_rebuild(source_files, ts_file):
  222. cmd = cmd_prefix
  223. cmd.append(os.fspath(ts_file))
  224. run_command(cmd, cwd=project_dir)
  225. def main(mode: str = None, dry_run: bool = False, quiet: bool = False, force: bool = False,
  226. qml_module: bool = None, project_dir: str = None, project_path: str = None,
  227. legacy_pyproject: bool = False):
  228. cl_options = ClOptions(dry_run=dry_run, quiet=quiet, # noqa: F841
  229. force=force, qml_module=qml_module)
  230. if new_project_type := NewProjectTypes.find_by_command(mode):
  231. if not project_dir:
  232. print(f"Error creating new project: {mode} requires a directory name or path",
  233. file=sys.stderr)
  234. sys.exit(1)
  235. project_dir = Path(project_dir)
  236. try:
  237. project_dir.resolve()
  238. project_dir.mkdir(parents=True, exist_ok=True)
  239. except (OSError, RuntimeError, ValueError):
  240. print("Invalid project name", file=sys.stderr)
  241. sys.exit(1)
  242. sys.exit(new_project(project_dir, new_project_type, legacy_pyproject))
  243. if mode == "migrate-pyproject":
  244. sys.exit(migrate_pyproject(project_path))
  245. try:
  246. project_file = resolve_valid_project_file(project_path)
  247. except ValueError as e:
  248. print(f"Error: {e}", file=sys.stderr)
  249. sys.exit(1)
  250. project = Project(project_file)
  251. if mode == "build":
  252. project.build()
  253. elif mode == "run":
  254. sys.exit(project.run())
  255. elif mode == "clean":
  256. project.clean()
  257. elif mode == "qmllint":
  258. project.qmllint()
  259. elif mode == "deploy":
  260. project.deploy()
  261. elif mode == "lupdate":
  262. project.lupdate()
  263. else:
  264. print(f"Invalid mode {mode}", file=sys.stderr)
  265. sys.exit(1)
  266. if __name__ == "__main__":
  267. parser = ArgumentParser(description=DESCRIPTION, formatter_class=RawTextHelpFormatter)
  268. parser.add_argument("--quiet", "-q", action="store_true", help="Quiet")
  269. parser.add_argument("--dry-run", "-n", action="store_true", help="Only print commands")
  270. parser.add_argument("--force", "-f", action="store_true", help="Force rebuild")
  271. parser.add_argument("--qml-module", "-Q", action="store_true",
  272. help="Perform check for QML module")
  273. # Create subparsers for the two different command branches
  274. subparsers = parser.add_subparsers(dest='mode', required=True)
  275. # Add subparser for project creation commands
  276. for project_type in NewProjectTypes:
  277. new_parser = subparsers.add_parser(project_type.value.command,
  278. help=project_type.value.description)
  279. new_parser.add_argument(
  280. "project_dir", help="Name or location of the new project", nargs="?", type=str)
  281. new_parser.add_argument(
  282. "--legacy-pyproject", action="store_true", help="Create a legacy *.pyproject file")
  283. # Add subparser for project operation commands
  284. for op_mode, op_help in OPERATION_HELP.items():
  285. op_parser = subparsers.add_parser(op_mode, help=op_help)
  286. op_parser.add_argument("project_path", nargs="?", type=str, help="Path to the project file")
  287. args = parser.parse_args()
  288. main(args.mode, args.dry_run, args.quiet, args.force, args.qml_module,
  289. getattr(args, "project_dir", None), getattr(args, "project_path", None),
  290. getattr(args, "legacy_pyproject", None))