config.py 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532
  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 configparser
  6. import logging
  7. import tempfile
  8. import warnings
  9. from configparser import ConfigParser
  10. from pathlib import Path
  11. from enum import Enum
  12. from project_lib import ProjectData, DesignStudioProject, resolve_valid_project_file
  13. from . import (DEFAULT_APP_ICON, DEFAULT_IGNORE_DIRS, find_pyside_modules,
  14. find_permission_categories, QtDependencyReader, run_qmlimportscanner)
  15. # Some QML plugins like QtCore are excluded from this list as they don't contribute much to
  16. # executable size. Excluding them saves the extra processing of checking for them in files
  17. EXCLUDED_QML_PLUGINS = {"QtQuick", "QtQuick3D", "QtCharts", "QtWebEngine", "QtTest", "QtSensors"}
  18. PERMISSION_MAP = {"Bluetooth": "NSBluetoothAlwaysUsageDescription:BluetoothAccess",
  19. "Camera": "NSCameraUsageDescription:CameraAccess",
  20. "Microphone": "NSMicrophoneUsageDescription:MicrophoneAccess",
  21. "Contacts": "NSContactsUsageDescription:ContactsAccess",
  22. "Calendar": "NSCalendarsUsageDescription:CalendarAccess",
  23. # for iOS NSLocationWhenInUseUsageDescription and
  24. # NSLocationAlwaysAndWhenInUseUsageDescription are also required.
  25. "Location": "NSLocationUsageDescription:LocationAccess",
  26. }
  27. class BaseConfig:
  28. """Wrapper class around any .spec file with function to read and set values for the .spec file
  29. """
  30. def __init__(self, config_file: Path, comment_prefixes: str = "/",
  31. existing_config_file: bool = False) -> None:
  32. self.config_file = config_file
  33. self.existing_config_file = existing_config_file
  34. self.parser = ConfigParser(comment_prefixes=comment_prefixes, strict=False,
  35. allow_no_value=True)
  36. self.parser.read(self.config_file)
  37. def update_config(self):
  38. logging.info(f"[DEPLOY] Updating config file {self.config_file}")
  39. # This section of code is done to preserve the formatting of the original deploy.spec
  40. # file where there is blank line before the comments
  41. with tempfile.NamedTemporaryFile('w+', delete=False) as temp_file:
  42. self.parser.write(temp_file, space_around_delimiters=True)
  43. temp_file_path = temp_file.name
  44. # Read the temporary file and write back to the original file with blank lines before
  45. # comments
  46. with open(temp_file_path, 'r') as temp_file, open(self.config_file, 'w') as config_file:
  47. previous_line = None
  48. for line in temp_file:
  49. if (line.lstrip().startswith('#') and previous_line is not None
  50. and not previous_line.lstrip().startswith('#')):
  51. config_file.write('\n')
  52. config_file.write(line)
  53. previous_line = line
  54. # Clean up the temporary file
  55. Path(temp_file_path).unlink()
  56. def set_value(self, section: str, key: str, new_value: str, raise_warning: bool = True) -> None:
  57. try:
  58. current_value = self.get_value(section, key, ignore_fail=True)
  59. if current_value != new_value:
  60. self.parser.set(section, key, new_value)
  61. except configparser.NoOptionError:
  62. if not raise_warning:
  63. return
  64. logging.warning(f"[DEPLOY] Set key '{key}': Key does not exist in section '{section}'")
  65. except configparser.NoSectionError:
  66. if not raise_warning:
  67. return
  68. logging.warning(f"[DEPLOY] Section '{section}' does not exist")
  69. def get_value(self, section: str, key: str, ignore_fail: bool = False) -> str | None:
  70. try:
  71. return self.parser.get(section, key)
  72. except configparser.NoOptionError:
  73. if ignore_fail:
  74. return None
  75. logging.warning(f"[DEPLOY] Get key '{key}': Key does not exist in section {section}")
  76. except configparser.NoSectionError:
  77. if ignore_fail:
  78. return None
  79. logging.warning(f"[DEPLOY] Section '{section}': does not exist")
  80. class Config(BaseConfig):
  81. """
  82. Wrapper class around pysidedeploy.spec file, whose options are used to control the executable
  83. creation
  84. """
  85. def __init__(self, config_file: Path, source_file: Path, python_exe: Path, dry_run: bool,
  86. existing_config_file: bool = False, extra_ignore_dirs: list[str] = None,
  87. name: str = None):
  88. super().__init__(config_file=config_file, existing_config_file=existing_config_file)
  89. self.extra_ignore_dirs = extra_ignore_dirs
  90. self._dry_run = dry_run
  91. self.qml_modules = set()
  92. self.source_file = Path(
  93. self.set_or_fetch(property_value=source_file, property_key="input_file")
  94. ).resolve()
  95. self.python_path = Path(
  96. self.set_or_fetch(
  97. property_value=python_exe,
  98. property_key="python_path",
  99. property_group="python",
  100. )
  101. )
  102. self.title = self.set_or_fetch(property_value=name, property_key="title")
  103. config_icon = self.get_value("app", "icon")
  104. if config_icon:
  105. self._icon = str(Path(config_icon).resolve())
  106. else:
  107. self.icon = DEFAULT_APP_ICON
  108. proj_dir = self.get_value("app", "project_dir")
  109. if proj_dir:
  110. self._project_dir = Path(proj_dir).resolve()
  111. else:
  112. self.project_dir = self._find_project_dir()
  113. exe_directory = self.get_value("app", "exec_directory")
  114. if exe_directory:
  115. self._exe_dir = Path(exe_directory).absolute()
  116. else:
  117. self.exe_dir = self._find_exe_dir()
  118. self._project_file = None
  119. proj_file = self.get_value("app", "project_file")
  120. if proj_file:
  121. self._project_file = self.project_dir / proj_file
  122. else:
  123. proj_file = self._find_project_file()
  124. if proj_file:
  125. self.project_file = proj_file
  126. self.project_data = None
  127. if self.project_file and self.project_file.exists():
  128. self.project_data = ProjectData(project_file=self.project_file)
  129. self._qml_files = []
  130. # Design Studio projects include the qml files using Qt resources
  131. if source_file and not DesignStudioProject.is_ds_project(source_file):
  132. config_qml_files = self.get_value("qt", "qml_files")
  133. if config_qml_files and self.project_dir and self.existing_config_file:
  134. self._qml_files = [Path(self.project_dir)
  135. / file for file in config_qml_files.split(",")]
  136. else:
  137. self.qml_files = self._find_qml_files()
  138. self._excluded_qml_plugins = []
  139. excl_qml_plugins = self.get_value("qt", "excluded_qml_plugins")
  140. if excl_qml_plugins and self.existing_config_file:
  141. self._excluded_qml_plugins = excl_qml_plugins.split(",")
  142. else:
  143. self.excluded_qml_plugins = self._find_excluded_qml_plugins()
  144. self._generated_files_path = self.source_file.parent / "deployment"
  145. self.modules = []
  146. def set_or_fetch(self, property_value, property_key, property_group="app") -> str:
  147. """
  148. If a new property value is provided, store it in the config file
  149. Otherwise return the existing value in the config file.
  150. Raise an exception if neither are available.
  151. :param property_value: The value to set if provided.
  152. :param property_key: The configuration key.
  153. :param property_group: The configuration group (default is "app").
  154. :return: The configuration value.
  155. :raises RuntimeError: If no value is provided and no existing value is found.
  156. """
  157. existing_value = self.get_value(property_group, property_key)
  158. if property_value:
  159. self.set_value(property_group, property_key, str(property_value))
  160. return property_value
  161. if existing_value:
  162. return existing_value
  163. raise RuntimeError(
  164. f"[DEPLOY] No value for {property_key} specified in config file or as cli option"
  165. )
  166. @property
  167. def dry_run(self) -> bool:
  168. return self._dry_run
  169. @property
  170. def generated_files_path(self) -> Path:
  171. return self._generated_files_path
  172. @property
  173. def qml_files(self) -> list[Path]:
  174. return self._qml_files
  175. @qml_files.setter
  176. def qml_files(self, qml_files: list[Path]):
  177. self._qml_files = qml_files
  178. qml_files = [str(file.absolute().relative_to(self.project_dir.absolute()))
  179. if file.absolute().is_relative_to(self.project_dir) else str(file.absolute())
  180. for file in self.qml_files]
  181. qml_files.sort()
  182. self.set_value("qt", "qml_files", ",".join(qml_files))
  183. @property
  184. def project_dir(self) -> Path:
  185. return self._project_dir
  186. @project_dir.setter
  187. def project_dir(self, project_dir: Path) -> None:
  188. rel_path = (
  189. project_dir.relative_to(self.config_file.parent)
  190. if project_dir.is_relative_to(self.config_file.parent)
  191. else project_dir
  192. )
  193. self._project_dir = project_dir
  194. self.set_value("app", "project_dir", str(rel_path))
  195. @property
  196. def project_file(self) -> Path:
  197. return self._project_file
  198. @project_file.setter
  199. def project_file(self, project_file: Path):
  200. self._project_file = project_file
  201. self.set_value("app", "project_file", str(project_file.relative_to(self.project_dir)))
  202. @property
  203. def title(self) -> str:
  204. return self._title
  205. @title.setter
  206. def title(self, title: str):
  207. self._title = title
  208. @property
  209. def icon(self) -> str:
  210. return self._icon
  211. @icon.setter
  212. def icon(self, icon: str):
  213. self._icon = icon
  214. self.set_value("app", "icon", icon)
  215. @property
  216. def source_file(self) -> Path:
  217. return self._source_file
  218. @source_file.setter
  219. def source_file(self, source_file: Path) -> None:
  220. rel_path = (
  221. source_file.relative_to(self.config_file.parent)
  222. if source_file.is_relative_to(self.config_file.parent)
  223. else source_file
  224. )
  225. self._source_file = source_file
  226. self.set_value("app", "input_file", str(rel_path))
  227. @property
  228. def python_path(self) -> Path:
  229. return self._python_path
  230. @python_path.setter
  231. def python_path(self, python_path: Path):
  232. self._python_path = python_path
  233. @property
  234. def extra_args(self) -> str:
  235. return self.get_value("nuitka", "extra_args")
  236. @extra_args.setter
  237. def extra_args(self, extra_args: str):
  238. self.set_value("nuitka", "extra_args", extra_args)
  239. @property
  240. def excluded_qml_plugins(self) -> list[str]:
  241. return self._excluded_qml_plugins
  242. @excluded_qml_plugins.setter
  243. def excluded_qml_plugins(self, excluded_qml_plugins: list[str]):
  244. self._excluded_qml_plugins = excluded_qml_plugins
  245. if excluded_qml_plugins: # check required for Android
  246. excluded_qml_plugins.sort()
  247. self.set_value("qt", "excluded_qml_plugins", ",".join(excluded_qml_plugins))
  248. @property
  249. def exe_dir(self) -> Path:
  250. return self._exe_dir
  251. @exe_dir.setter
  252. def exe_dir(self, exe_dir: Path):
  253. self._exe_dir = exe_dir
  254. self.set_value("app", "exec_directory", str(exe_dir))
  255. @property
  256. def modules(self) -> list[str]:
  257. return self._modules
  258. @modules.setter
  259. def modules(self, modules: list[str]):
  260. self._modules = modules
  261. modules.sort()
  262. self.set_value("qt", "modules", ",".join(modules))
  263. def _find_qml_files(self):
  264. """
  265. Fetches all the qml_files in the folder and sets them if the
  266. field qml_files is empty in the config_file
  267. """
  268. if self.project_data:
  269. qml_files = [(self.project_dir / str(qml_file)) for qml_file in
  270. self.project_data.qml_files]
  271. for sub_project_file in self.project_data.sub_projects_files:
  272. qml_files.extend([self.project_dir / str(qml_file) for qml_file in
  273. ProjectData(project_file=sub_project_file).qml_files])
  274. else:
  275. # Filter out files from DEFAULT_IGNORE_DIRS
  276. qml_files = [
  277. file for file in self.project_dir.glob("**/*.qml")
  278. if all(part not in file.parts for part in DEFAULT_IGNORE_DIRS)
  279. ]
  280. if len(qml_files) > 500:
  281. warnings.warn(
  282. "You seem to include a lot of QML files from "
  283. f"{self.project_dir}. This can lead to errors in deployment."
  284. )
  285. return qml_files
  286. def _find_project_dir(self) -> Path:
  287. if DesignStudioProject.is_ds_project(self.source_file):
  288. return DesignStudioProject(self.source_file).project_dir
  289. # There is no other way to find the project_dir than assume it is the parent directory
  290. # of source_file
  291. return self.source_file.parent
  292. def _find_project_file(self) -> Path | None:
  293. if not self.source_file:
  294. raise RuntimeError("[DEPLOY] Source file not set in config file")
  295. if DesignStudioProject.is_ds_project(self.source_file):
  296. pyproject_location = self.source_file.parent
  297. else:
  298. pyproject_location = self.project_dir
  299. try:
  300. return resolve_valid_project_file(pyproject_location)
  301. except ValueError as e:
  302. logging.warning(f"[DEPLOY] Unable to resolve a valid project file. Proceeding without a"
  303. f" project file. Details:\n{e}.")
  304. return None
  305. def _find_excluded_qml_plugins(self) -> list[str] | None:
  306. if not self.qml_files and not DesignStudioProject.is_ds_project(self.source_file):
  307. return None
  308. self.qml_modules = set(run_qmlimportscanner(project_dir=self.project_dir,
  309. dry_run=self.dry_run))
  310. excluded_qml_plugins = EXCLUDED_QML_PLUGINS.difference(self.qml_modules)
  311. # sorting needed for dry_run testing
  312. return sorted(excluded_qml_plugins)
  313. def _find_exe_dir(self) -> Path:
  314. if self.project_dir == Path.cwd():
  315. return self.project_dir.relative_to(Path.cwd())
  316. return self.project_dir
  317. def _find_pysidemodules(self) -> list[str]:
  318. modules = find_pyside_modules(project_dir=self.project_dir,
  319. extra_ignore_dirs=self.extra_ignore_dirs,
  320. project_data=self.project_data)
  321. logging.info("The following PySide modules were found from the Python files of "
  322. f"the project {modules}")
  323. return modules
  324. def _find_qtquick_modules(self) -> list[str]:
  325. """Identify if QtQuick is used in QML files and add them as dependency
  326. """
  327. extra_modules = []
  328. if not self.qml_modules and self.qml_files:
  329. self.qml_modules = set(run_qmlimportscanner(project_dir=self.project_dir,
  330. dry_run=self.dry_run))
  331. if "QtQuick" in self.qml_modules:
  332. extra_modules.append("Quick")
  333. if "QtQuick.Controls" in self.qml_modules:
  334. extra_modules.append("QuickControls2")
  335. return extra_modules
  336. class DesktopConfig(Config):
  337. """Wrapper class around pysidedeploy.spec, but specific to Desktop deployment
  338. """
  339. class NuitkaMode(Enum):
  340. ONEFILE = "onefile"
  341. STANDALONE = "standalone"
  342. def __init__(self, config_file: Path, source_file: Path, python_exe: Path, dry_run: bool,
  343. existing_config_file: bool = False, extra_ignore_dirs: list[str] = None,
  344. mode: str = "onefile", name: str = None):
  345. super().__init__(config_file, source_file, python_exe, dry_run, existing_config_file,
  346. extra_ignore_dirs, name=name)
  347. self.dependency_reader = QtDependencyReader(dry_run=self.dry_run)
  348. modules = self.get_value("qt", "modules")
  349. if modules:
  350. self._modules = modules.split(",")
  351. else:
  352. modules = self._find_pysidemodules()
  353. modules += self._find_qtquick_modules()
  354. modules += self._find_dependent_qt_modules(modules=modules)
  355. # remove duplicates
  356. self.modules = list(set(modules))
  357. self._qt_plugins = []
  358. if self.get_value("qt", "plugins"):
  359. self._qt_plugins = self.get_value("qt", "plugins").split(",")
  360. else:
  361. self.qt_plugins = self.dependency_reader.find_plugin_dependencies(self.modules,
  362. python_exe)
  363. self._permissions = []
  364. if sys.platform == "darwin":
  365. nuitka_macos_permissions = self.get_value("nuitka", "macos.permissions")
  366. if nuitka_macos_permissions:
  367. self._permissions = nuitka_macos_permissions.split(",")
  368. else:
  369. self.permissions = self._find_permissions()
  370. self._mode = self.NuitkaMode.ONEFILE
  371. if self.get_value("nuitka", "mode") == self.NuitkaMode.STANDALONE.value:
  372. self._mode = self.NuitkaMode.STANDALONE
  373. elif mode == self.NuitkaMode.STANDALONE.value:
  374. self.mode = self.NuitkaMode.STANDALONE
  375. if DesignStudioProject.is_ds_project(self.source_file):
  376. ds_project = DesignStudioProject(self.source_file)
  377. if not ds_project.compiled_resources_available():
  378. raise RuntimeError(f"[DEPLOY] Compiled resources file not found: "
  379. f"{ds_project.compiled_resources_file.absolute()}. "
  380. f"Build the project using 'pyside6-project build' or compile "
  381. f"the resources manually using pyside6-rcc")
  382. @property
  383. def qt_plugins(self) -> list[str]:
  384. return self._qt_plugins
  385. @qt_plugins.setter
  386. def qt_plugins(self, qt_plugins: list[str]):
  387. self._qt_plugins = qt_plugins
  388. qt_plugins.sort()
  389. self.set_value("qt", "plugins", ",".join(qt_plugins))
  390. @property
  391. def permissions(self) -> list[str]:
  392. return self._permissions
  393. @permissions.setter
  394. def permissions(self, permissions: list[str]):
  395. self._permissions = permissions
  396. permissions.sort()
  397. self.set_value("nuitka", "macos.permissions", ",".join(permissions))
  398. @property
  399. def mode(self) -> NuitkaMode:
  400. return self._mode
  401. @mode.setter
  402. def mode(self, mode: NuitkaMode):
  403. self._mode = mode
  404. self.set_value("nuitka", "mode", mode.value)
  405. def _find_dependent_qt_modules(self, modules: list[str]) -> list[str]:
  406. """
  407. Given pysidedeploy_config.modules, find all the other dependent Qt modules.
  408. """
  409. all_modules = set(modules)
  410. if not self.dependency_reader.lib_reader:
  411. warnings.warn(f"[DEPLOY] Unable to find {self.dependency_reader.lib_reader_name}. This "
  412. f"tool helps to find the Qt module dependencies of the application. "
  413. f"Skipping checking for dependencies.", category=RuntimeWarning)
  414. return []
  415. for module_name in modules:
  416. self.dependency_reader.find_dependencies(module=module_name, used_modules=all_modules)
  417. return list(all_modules)
  418. def _find_permissions(self) -> list[str]:
  419. """
  420. Finds and sets the usage description string required for each permission requested by the
  421. macOS application.
  422. """
  423. permissions = []
  424. perm_categories = find_permission_categories(project_dir=self.project_dir,
  425. extra_ignore_dirs=self.extra_ignore_dirs,
  426. project_data=self.project_data)
  427. perm_categories_str = ",".join(perm_categories)
  428. logging.info(f"[DEPLOY] Usage descriptions for the {perm_categories_str} will be added to "
  429. "the Info.plist file of the macOS application bundle")
  430. # Handling permissions
  431. for perm_category in perm_categories:
  432. if perm_category in PERMISSION_MAP:
  433. permissions.append(PERMISSION_MAP[perm_category])
  434. return permissions