dependency_util.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337
  1. # Copyright (C) 2024 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 ast
  5. import re
  6. import os
  7. import site
  8. import json
  9. import warnings
  10. import logging
  11. import shutil
  12. import sys
  13. from pathlib import Path
  14. from functools import lru_cache
  15. from . import IMPORT_WARNING_PYSIDE, DEFAULT_IGNORE_DIRS, run_command
  16. @lru_cache(maxsize=None)
  17. def get_py_files(project_dir: Path, extra_ignore_dirs: tuple[Path] = None, project_data=None):
  18. """Finds and returns all the Python files in the project
  19. """
  20. py_candidates = []
  21. ignore_dirs = DEFAULT_IGNORE_DIRS.copy()
  22. if project_data:
  23. py_candidates = project_data.python_files
  24. ui_candidates = project_data.ui_files
  25. qrc_candidates = project_data.qrc_files
  26. def add_uic_qrc_candidates(candidates, candidate_type):
  27. possible_py_candidates = []
  28. missing_files = []
  29. for file in candidates:
  30. py_file = file.parent / f"{candidate_type}_{file.stem}.py"
  31. if py_file.exists():
  32. possible_py_candidates.append(py_file)
  33. else:
  34. missing_files.append((str(file), str(py_file)))
  35. if missing_files:
  36. missing_details = "\n".join(
  37. f"{candidate_type.upper()} file: {src} -> Missing Python file: {dst}"
  38. for src, dst in missing_files
  39. )
  40. warnings.warn(
  41. f"[DEPLOY] The following {candidate_type} files do not have corresponding "
  42. f"Python files:\n {missing_details}",
  43. category=RuntimeWarning
  44. )
  45. py_candidates.extend(possible_py_candidates)
  46. if ui_candidates:
  47. add_uic_qrc_candidates(ui_candidates, "ui")
  48. if qrc_candidates:
  49. add_uic_qrc_candidates(qrc_candidates, "rc")
  50. return py_candidates
  51. # incase there is not .pyproject file, search all python files in project_dir, except
  52. # ignore_dirs
  53. if extra_ignore_dirs:
  54. ignore_dirs.update(extra_ignore_dirs)
  55. # find relevant .py files
  56. _walk = os.walk(project_dir)
  57. for root, dirs, files in _walk:
  58. dirs[:] = [d for d in dirs if d not in ignore_dirs and not d.startswith(".")]
  59. for py_file in files:
  60. if py_file.endswith(".py"):
  61. py_candidates.append(Path(root) / py_file)
  62. return py_candidates
  63. @lru_cache(maxsize=None)
  64. def get_ast(py_file: Path):
  65. """Given a Python file returns the abstract syntax tree
  66. """
  67. contents = py_file.read_text(encoding="utf-8")
  68. try:
  69. tree = ast.parse(contents)
  70. except SyntaxError:
  71. print(f"[DEPLOY] Unable to parse {py_file}")
  72. return tree
  73. def find_permission_categories(project_dir: Path, extra_ignore_dirs: list[Path] = None,
  74. project_data=None):
  75. """Given the project directory, finds all the permission categories required by the
  76. project. eg: Camera, Bluetooth, Contacts etc.
  77. Note: This function is only relevant for mac0S deployment.
  78. """
  79. all_perm_categories = set()
  80. mod_pattern = re.compile("Q(?P<mod_name>.*)Permission")
  81. def pyside_permission_imports(py_file: Path):
  82. perm_categories = []
  83. try:
  84. tree = get_ast(py_file)
  85. for node in ast.walk(tree):
  86. if isinstance(node, ast.ImportFrom):
  87. main_mod_name = node.module
  88. if main_mod_name == "PySide6.QtCore":
  89. # considers 'from PySide6.QtCore import QtMicrophonePermission'
  90. for imported_module in node.names:
  91. full_mod_name = imported_module.name
  92. match = mod_pattern.search(full_mod_name)
  93. if match:
  94. mod_name = match.group("mod_name")
  95. perm_categories.append(mod_name)
  96. continue
  97. if isinstance(node, ast.Import):
  98. for imported_module in node.names:
  99. full_mod_name = imported_module.name
  100. if full_mod_name == "PySide6":
  101. logging.warning(IMPORT_WARNING_PYSIDE.format(str(py_file)))
  102. except Exception as e:
  103. raise RuntimeError(f"[DEPLOY] Finding permission categories failed on file "
  104. f"{str(py_file)} with error {e}")
  105. return set(perm_categories)
  106. if extra_ignore_dirs is not None:
  107. extra_ignore_dirs = tuple(extra_ignore_dirs)
  108. py_candidates = get_py_files(project_dir, extra_ignore_dirs, project_data)
  109. for py_candidate in py_candidates:
  110. all_perm_categories = all_perm_categories.union(pyside_permission_imports(py_candidate))
  111. if not all_perm_categories:
  112. ValueError("[DEPLOY] No permission categories were found for macOS app bundle creation.")
  113. return all_perm_categories
  114. def find_pyside_modules(project_dir: Path, extra_ignore_dirs: list[Path] = None,
  115. project_data=None):
  116. """
  117. Searches all the python files in the project to find all the PySide modules used by
  118. the application.
  119. """
  120. all_modules = set()
  121. mod_pattern = re.compile("PySide6.Qt(?P<mod_name>.*)")
  122. @lru_cache
  123. def pyside_module_imports(py_file: Path):
  124. modules = []
  125. try:
  126. tree = get_ast(py_file)
  127. for node in ast.walk(tree):
  128. if isinstance(node, ast.ImportFrom):
  129. main_mod_name = node.module
  130. if main_mod_name and main_mod_name.startswith("PySide6"):
  131. if main_mod_name == "PySide6":
  132. # considers 'from PySide6 import QtCore'
  133. for imported_module in node.names:
  134. full_mod_name = imported_module.name
  135. if full_mod_name.startswith("Qt"):
  136. modules.append(full_mod_name[2:])
  137. continue
  138. # considers 'from PySide6.QtCore import Qt'
  139. match = mod_pattern.search(main_mod_name)
  140. if match:
  141. mod_name = match.group("mod_name")
  142. modules.append(mod_name)
  143. else:
  144. logging.warning((
  145. f"[DEPLOY] Unable to find module name from {ast.dump(node)}"))
  146. if isinstance(node, ast.Import):
  147. for imported_module in node.names:
  148. full_mod_name = imported_module.name
  149. if full_mod_name == "PySide6":
  150. logging.warning(IMPORT_WARNING_PYSIDE.format(str(py_file)))
  151. except Exception as e:
  152. raise RuntimeError(f"[DEPLOY] Finding module import failed on file {str(py_file)} with "
  153. f"error {e}")
  154. return set(modules)
  155. if extra_ignore_dirs is not None:
  156. extra_ignore_dirs = tuple(extra_ignore_dirs)
  157. py_candidates = get_py_files(project_dir, extra_ignore_dirs, project_data)
  158. for py_candidate in py_candidates:
  159. all_modules = all_modules.union(pyside_module_imports(py_candidate))
  160. if not all_modules:
  161. ValueError("[DEPLOY] No PySide6 modules were found")
  162. return list(all_modules)
  163. class QtDependencyReader:
  164. def __init__(self, dry_run: bool = False) -> None:
  165. self.dry_run = dry_run
  166. self.lib_reader_name = None
  167. self.qt_module_path_pattern = None
  168. self.lib_pattern = None
  169. self.command = None
  170. self.qt_libs_dir = None
  171. if sys.platform == "linux":
  172. self.lib_reader_name = "readelf"
  173. self.qt_module_path_pattern = "libQt6{module}.so.6"
  174. self.lib_pattern = re.compile("libQt6(?P<mod_name>.*).so.6")
  175. self.command_args = "-d"
  176. elif sys.platform == "darwin":
  177. self.lib_reader_name = "dyld_info"
  178. self.qt_module_path_pattern = "Qt{module}.framework/Versions/A/Qt{module}"
  179. self.lib_pattern = re.compile("@rpath/Qt(?P<mod_name>.*).framework/Versions/A/")
  180. self.command_args = "-dependents"
  181. elif sys.platform == "win32":
  182. self.lib_reader_name = "dumpbin"
  183. self.qt_module_path_pattern = "Qt6{module}.dll"
  184. self.lib_pattern = re.compile("Qt6(?P<mod_name>.*).dll")
  185. self.command_args = "/dependents"
  186. else:
  187. print(f"[DEPLOY] Deployment on unsupported platfrom {sys.platform}")
  188. sys.exit(1)
  189. self.pyside_install_dir = None
  190. self.qt_libs_dir = self.get_qt_libs_dir()
  191. self._lib_reader = shutil.which(self.lib_reader_name)
  192. def get_qt_libs_dir(self):
  193. """
  194. Finds the path to the Qt libs directory inside PySide6 package installation
  195. """
  196. # PYSIDE-2785 consider dist-packages for Debian based systems
  197. for possible_site_package in site.getsitepackages():
  198. if possible_site_package.endswith(("site-packages", "dist-packages")):
  199. self.pyside_install_dir = Path(possible_site_package) / "PySide6"
  200. if self.pyside_install_dir.exists():
  201. break
  202. if not self.pyside_install_dir:
  203. print("Unable to find where PySide6 is installed. Exiting ...")
  204. sys.exit(-1)
  205. if sys.platform == "win32":
  206. return self.pyside_install_dir
  207. return self.pyside_install_dir / "Qt" / "lib" # for linux and macOS
  208. @property
  209. def lib_reader(self):
  210. return self._lib_reader
  211. def find_dependencies(self, module: str, used_modules: set[str] = None):
  212. """
  213. Given a Qt module, find all the other Qt modules it is dependent on and add it to the
  214. 'used_modules' set
  215. """
  216. qt_module_path = self.qt_libs_dir / self.qt_module_path_pattern.format(module=module)
  217. if not qt_module_path.exists():
  218. warnings.warn(f"[DEPLOY] {qt_module_path.name} not found in {str(qt_module_path)}."
  219. "Skipping finding its dependencies.", category=RuntimeWarning)
  220. return
  221. lib_pattern = re.compile(self.lib_pattern)
  222. command = [self.lib_reader, self.command_args, str(qt_module_path)]
  223. # print the command if dry_run is True.
  224. # Normally run_command is going to print the command in dry_run mode. But, this is a
  225. # special case where we need to print the command as well as to run it.
  226. if self.dry_run:
  227. command_str = " ".join(command)
  228. print(command_str + "\n")
  229. # We need to run this even for dry run, to see the full Nuitka command being executed
  230. _, output = run_command(command=command, dry_run=False, fetch_output=True)
  231. dependent_modules = set()
  232. for line in output.splitlines():
  233. line = line.decode("utf-8").lstrip()
  234. if sys.platform == "darwin":
  235. if line.endswith(f"Qt{module} [arm64]:"):
  236. # macOS Qt frameworks bundles have both x86_64 and arm64 architectures
  237. # We only need to consider one as the dependencies are redundant
  238. break
  239. elif line.endswith(f"Qt{module} [X86_64]:"):
  240. # this line needs to be skipped because it matches with the pattern
  241. # and is related to the module itself, not the dependencies of the module
  242. continue
  243. elif sys.platform == "win32" and line.startswith("Summary"):
  244. # the dependencies would be found before the `Summary` line
  245. break
  246. match = lib_pattern.search(line)
  247. if match:
  248. dep_module = match.group("mod_name")
  249. dependent_modules.add(dep_module)
  250. if dep_module not in used_modules:
  251. used_modules.add(dep_module)
  252. self.find_dependencies(module=dep_module, used_modules=used_modules)
  253. if dependent_modules:
  254. logging.info(f"[DEPLOY] Following dependencies found for {module}: {dependent_modules}")
  255. else:
  256. logging.info(f"[DEPLOY] No Qt dependencies found for {module}")
  257. def find_plugin_dependencies(self, used_modules: list[str], python_exe: Path) -> list[str]:
  258. """
  259. Given the modules used by the application, returns all the required plugins
  260. """
  261. plugins = set()
  262. pyside_wheels = ["PySide6_Essentials", "PySide6_Addons"]
  263. # TODO from 3.12 use list(dist.name for dist in importlib.metadata.distributions())
  264. _, installed_packages = run_command(command=[str(python_exe), "-m", "pip", "freeze"],
  265. dry_run=False, fetch_output=True)
  266. installed_packages = [p.decode().split('==')[0] for p in installed_packages.split()]
  267. for pyside_wheel in pyside_wheels:
  268. if pyside_wheel not in installed_packages:
  269. # the wheel is not installed and hence no plugins are checked for its modules
  270. logging.warning((f"[DEPLOY] The package {pyside_wheel} is not installed. "))
  271. continue
  272. pyside_mod_plugin_json_name = f"{pyside_wheel}.json"
  273. pyside_mod_plugin_json_file = self.pyside_install_dir / pyside_mod_plugin_json_name
  274. if not pyside_mod_plugin_json_file.exists():
  275. warnings.warn(f"[DEPLOY] Unable to find {pyside_mod_plugin_json_file}.",
  276. category=RuntimeWarning)
  277. continue
  278. # convert the json to dict
  279. pyside_mod_dict = {}
  280. with open(pyside_mod_plugin_json_file) as pyside_json:
  281. pyside_mod_dict = json.load(pyside_json)
  282. # find all the plugins in the modules
  283. for module in used_modules:
  284. plugins.update(pyside_mod_dict.get(module, []))
  285. return list(plugins)