PackageList.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305
  1. # Copyright (c) 2021 Ultimaker B.V.
  2. # Cura is released under the terms of the LGPLv3 or higher.
  3. import tempfile
  4. import json
  5. import os.path
  6. from PyQt6.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, Qt
  7. from typing import cast, Dict, Optional, Set, TYPE_CHECKING
  8. from UM.i18n import i18nCatalog
  9. from UM.Qt.ListModel import ListModel
  10. from UM.TaskManagement.HttpRequestScope import JsonDecoratorScope
  11. from UM.TaskManagement.HttpRequestManager import HttpRequestData, HttpRequestManager
  12. from UM.Logger import Logger
  13. from UM import PluginRegistry
  14. from cura.CuraApplication import CuraApplication
  15. from cura.CuraPackageManager import CuraPackageManager
  16. from cura.UltimakerCloud.UltimakerCloudScope import UltimakerCloudScope # To make requests to the Ultimaker API with correct authorization.
  17. from .PackageModel import PackageModel
  18. from .Constants import USER_PACKAGES_URL, PACKAGES_URL
  19. if TYPE_CHECKING:
  20. from PyQt6.QtCore import QObject
  21. from PyQt6.QtNetwork import QNetworkReply
  22. catalog = i18nCatalog("cura")
  23. class PackageList(ListModel):
  24. """ A List model for Packages, this class serves as parent class for more detailed implementations.
  25. such as Packages obtained from Remote or Local source
  26. """
  27. PackageRole = Qt.ItemDataRole.UserRole + 1
  28. DISK_WRITE_BUFFER_SIZE = 256 * 1024 # 256 KB
  29. def __init__(self, parent: Optional["QObject"] = None) -> None:
  30. super().__init__(parent)
  31. self._package_manager: CuraPackageManager = cast(CuraPackageManager, CuraApplication.getInstance().getPackageManager())
  32. self._plugin_registry: PluginRegistry = CuraApplication.getInstance().getPluginRegistry()
  33. self._account = CuraApplication.getInstance().getCuraAPI().account
  34. self._error_message = ""
  35. self.addRoleName(self.PackageRole, "package")
  36. self._is_loading = False
  37. self._has_more = False
  38. self._has_footer = True
  39. self._to_install: Dict[str, str] = {}
  40. self._ongoing_requests: Dict[str, Optional[HttpRequestData]] = {"download_package": None}
  41. self._scope = JsonDecoratorScope(UltimakerCloudScope(CuraApplication.getInstance()))
  42. self._license_dialogs: Dict[str, QObject] = {}
  43. def __del__(self) -> None:
  44. """ When this object is deleted it will loop through all registered API requests and aborts them """
  45. try:
  46. self.isLoadingChanged.disconnect()
  47. self.hasMoreChanged.disconnect()
  48. except RuntimeError:
  49. pass
  50. self.cleanUpAPIRequest()
  51. def abortRequest(self, request_id: str) -> None:
  52. """Aborts a single request"""
  53. if request_id in self._ongoing_requests and self._ongoing_requests[request_id]:
  54. HttpRequestManager.getInstance().abortRequest(self._ongoing_requests[request_id])
  55. self._ongoing_requests[request_id] = None
  56. @pyqtSlot()
  57. def cleanUpAPIRequest(self) -> None:
  58. for request_id in self._ongoing_requests:
  59. self.abortRequest(request_id)
  60. @pyqtSlot()
  61. def updatePackages(self) -> None:
  62. """ A Qt slot which will update the List from a source. Actual implementation should be done in the child class"""
  63. pass
  64. def reset(self) -> None:
  65. """ Resets and clears the list"""
  66. self.clear()
  67. isLoadingChanged = pyqtSignal()
  68. def setIsLoading(self, value: bool) -> None:
  69. if self._is_loading != value:
  70. self._is_loading = value
  71. self.isLoadingChanged.emit()
  72. @pyqtProperty(bool, fset = setIsLoading, notify = isLoadingChanged)
  73. def isLoading(self) -> bool:
  74. """ Indicating if the the packages are loading
  75. :return" ``True`` if the list is being obtained, otherwise ``False``
  76. """
  77. return self._is_loading
  78. hasMoreChanged = pyqtSignal()
  79. def setHasMore(self, value: bool) -> None:
  80. if self._has_more != value:
  81. self._has_more = value
  82. self.hasMoreChanged.emit()
  83. @pyqtProperty(bool, fset = setHasMore, notify = hasMoreChanged)
  84. def hasMore(self) -> bool:
  85. """ Indicating if there are more packages available to load.
  86. :return: ``True`` if there are more packages to load, or ``False``.
  87. """
  88. return self._has_more
  89. errorMessageChanged = pyqtSignal()
  90. def setErrorMessage(self, error_message: str) -> None:
  91. if self._error_message != error_message:
  92. self._error_message = error_message
  93. self.errorMessageChanged.emit()
  94. @pyqtProperty(str, notify = errorMessageChanged, fset = setErrorMessage)
  95. def errorMessage(self) -> str:
  96. """ If an error occurred getting the list of packages, an error message will be held here.
  97. If no error occurred (yet), this will be an empty string.
  98. :return: An error message, if any, or an empty string if everything went okay.
  99. """
  100. return self._error_message
  101. @pyqtProperty(bool, constant = True)
  102. def hasFooter(self) -> bool:
  103. """ Indicating if the PackageList should have a Footer visible. For paginated PackageLists
  104. :return: ``True`` if a Footer should be displayed in the ListView, e.q.: paginated lists, ``False`` Otherwise"""
  105. return self._has_footer
  106. def getPackageModel(self, package_id: str) -> Optional[PackageModel]:
  107. index = self.find("package", package_id)
  108. data = self.getItem(index)
  109. if data:
  110. return data.get("package")
  111. return None
  112. def _openLicenseDialog(self, package_id: str, license_content: str) -> None:
  113. plugin_path = self._plugin_registry.getPluginPath("Marketplace")
  114. if plugin_path is None:
  115. plugin_path = os.path.dirname(__file__)
  116. # create a QML component for the license dialog
  117. license_dialog_component_path = os.path.join(plugin_path, "resources", "qml", "LicenseDialog.qml")
  118. dialog = CuraApplication.getInstance().createQmlComponent(license_dialog_component_path, {
  119. "licenseContent": license_content,
  120. "packageId": package_id,
  121. "handler": self
  122. })
  123. dialog.show()
  124. # place dialog in class such that it does not get remove by garbage collector
  125. self._license_dialogs[package_id] = dialog
  126. @pyqtSlot(str)
  127. def onLicenseAccepted(self, package_id: str) -> None:
  128. # close dialog
  129. dialog = self._license_dialogs.pop(package_id)
  130. if dialog is not None:
  131. dialog.deleteLater()
  132. # install relevant package
  133. self._install(package_id)
  134. @pyqtSlot(str)
  135. def onLicenseDeclined(self, package_id: str) -> None:
  136. # close dialog
  137. dialog = self._license_dialogs.pop(package_id)
  138. if dialog is not None:
  139. dialog.deleteLater()
  140. # reset package card
  141. self._package_manager.packageInstallingFailed.emit(package_id)
  142. def _requestInstall(self, package_id: str, update: bool = False) -> None:
  143. package_path = self._to_install[package_id]
  144. license_content = self._package_manager.getPackageLicense(package_path)
  145. if not update and license_content is not None and license_content != "":
  146. # If installation is not and update, and the packages contains a license then
  147. # open dialog, prompting the using to accept the plugin license
  148. self._openLicenseDialog(package_id, license_content)
  149. else:
  150. # Otherwise continue the installation
  151. self._install(package_id, update)
  152. def _install(self, package_id: str, update: bool = False) -> None:
  153. package_path = self._to_install.pop(package_id)
  154. to_be_installed = self._package_manager.installPackage(package_path) is not None
  155. if not to_be_installed:
  156. Logger.warning(f"Could not install {package_id}")
  157. return
  158. package = self.getPackageModel(package_id)
  159. if package:
  160. self.subscribeUserToPackage(package_id, str(package.sdk_version))
  161. else:
  162. Logger.log("w", f"Unable to get data on package {package_id}")
  163. def download(self, package_id: str, url: str, update: bool = False) -> None:
  164. """Initiate the download request
  165. :param package_id: the package identification string
  166. :param url: the URL from which the package needs to be obtained
  167. :param update: A flag if this is download request is an update process
  168. """
  169. if url == "":
  170. url = f"{PACKAGES_URL}/{package_id}/download"
  171. def downloadFinished(reply: "QNetworkReply") -> None:
  172. self._downloadFinished(package_id, reply, update)
  173. def downloadError(reply: "QNetworkReply", error: "QNetworkReply.NetworkError") -> None:
  174. self._downloadError(package_id, update, reply, error)
  175. self._ongoing_requests["download_package"] = HttpRequestManager.getInstance().get(
  176. url,
  177. scope = self._scope,
  178. callback = downloadFinished,
  179. error_callback = downloadError
  180. )
  181. def _downloadFinished(self, package_id: str, reply: "QNetworkReply", update: bool = False) -> None:
  182. with tempfile.NamedTemporaryFile(mode = "wb+", suffix = ".curapackage", delete = False) as temp_file:
  183. try:
  184. bytes_read = reply.read(self.DISK_WRITE_BUFFER_SIZE)
  185. while bytes_read:
  186. temp_file.write(bytes_read)
  187. bytes_read = reply.read(self.DISK_WRITE_BUFFER_SIZE)
  188. except IOError as e:
  189. Logger.error(f"Failed to write downloaded package to temp file {e}")
  190. temp_file.close()
  191. self._downloadError(package_id, update)
  192. except RuntimeError:
  193. # Setting the ownership of this object to not qml can still result in a RuntimeError. Which can occur when quickly toggling
  194. # between de-/constructing Remote or Local PackageLists. This try-except is here to prevent a hard crash when the wrapped C++ object
  195. # was deleted when it was still parsing the response
  196. temp_file.close()
  197. return
  198. temp_file.close()
  199. self._to_install[package_id] = temp_file.name
  200. self._ongoing_requests["download_package"] = None
  201. self._requestInstall(package_id, update)
  202. def _downloadError(self, package_id: str, update: bool = False, reply: Optional["QNetworkReply"] = None, error: Optional["QNetworkReply.NetworkError"] = None) -> None:
  203. if reply:
  204. reply_string = bytes(reply.readAll()).decode()
  205. Logger.error(f"Failed to download package: {package_id} due to {reply_string}")
  206. self._package_manager.packageInstallingFailed.emit(package_id)
  207. def subscribeUserToPackage(self, package_id: str, sdk_version: str) -> None:
  208. """Subscribe the user (if logged in) to the package for a given SDK
  209. :param package_id: the package identification string
  210. :param sdk_version: the SDK version
  211. """
  212. if self._account.isLoggedIn:
  213. HttpRequestManager.getInstance().put(
  214. url = USER_PACKAGES_URL,
  215. data = json.dumps({"data": {"package_id": package_id, "sdk_version": sdk_version}}).encode(),
  216. scope = self._scope
  217. )
  218. def unsunscribeUserFromPackage(self, package_id: str) -> None:
  219. """Unsubscribe the user (if logged in) from the package
  220. :param package_id: the package identification string
  221. """
  222. if self._account.isLoggedIn:
  223. HttpRequestManager.getInstance().delete(url = f"{USER_PACKAGES_URL}/{package_id}", scope = self._scope)
  224. # --- Handle the manage package buttons ---
  225. def _connectManageButtonSignals(self, package: PackageModel) -> None:
  226. package.installPackageTriggered.connect(self.installPackage)
  227. package.uninstallPackageTriggered.connect(self.uninstallPackage)
  228. package.updatePackageTriggered.connect(self.updatePackage)
  229. def installPackage(self, package_id: str, url: str) -> None:
  230. """Install a package from the Marketplace
  231. :param package_id: the package identification string
  232. """
  233. if not self._package_manager.reinstallPackage(package_id):
  234. self.download(package_id, url, False)
  235. else:
  236. package = self.getPackageModel(package_id)
  237. if package:
  238. self.subscribeUserToPackage(package_id, str(package.sdk_version))
  239. def uninstallPackage(self, package_id: str) -> None:
  240. """Uninstall a package from the Marketplace
  241. :param package_id: the package identification string
  242. """
  243. self._package_manager.removePackage(package_id)
  244. self.unsunscribeUserFromPackage(package_id)
  245. def updatePackage(self, package_id: str, url: str) -> None:
  246. """Update a package from the Marketplace
  247. :param package_id: the package identification string
  248. """
  249. self._package_manager.removePackage(package_id, force_add = not self._package_manager.isBundledPackage(package_id))
  250. self.download(package_id, url, True)