--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/eric7/MicroPython/CircuitPythonUpdater/CircuitPythonUpdaterInterface.py Mon Feb 06 10:09:18 2023 +0100 @@ -0,0 +1,652 @@ +# -*- coding: utf-8 -*- + +# Copyright (c) 2023 Detlev Offenbach <detlev@die-offenbachs.de> +# + +""" +Module implementing an interface to the 'circup' package. +""" + +import importlib +import logging +import os +import re +import shutil + +import requests + +from PyQt6.QtCore import QObject, pyqtSlot +from PyQt6.QtWidgets import QDialog, QInputDialog, QLineEdit, QMenu + +from eric7 import Preferences +from eric7.EricGui.EricOverrideCursor import EricOverrideCursor +from eric7.EricWidgets import EricFileDialog, EricMessageBox +from eric7.EricWidgets.EricApplication import ericApp +from eric7.EricWidgets.EricListSelectionDialog import EricListSelectionDialog +from eric7.SystemUtilities import PythonUtilities + +try: + import circup + + circup.logger.setLevel(logging.WARNING) +except ImportError: + circup = None + + +class CircuitPythonUpdaterInterface(QObject): + """ + Class implementing an interface to the 'circup' package. + """ + + def __init__(self, device, parent=None): + """ + Constructor + + @param device reference to the CircuitPython device interface + @type CircuitPythonDevice + @param parent reference to the parent object (defaults to None) + @type QObject (optional) + """ + super().__init__(parent) + + self.__device = device + + self.__installMenu = QMenu(self.tr("Install Modules")) + self.__installMenu.setTearOffEnabled(True) + self.__installMenu.addAction( + self.tr("Select from Available Modules"), self.__installFromAvailable + ) + self.__installMenu.addAction( + self.tr("Install Requirements"), self.__installRequirements + ) + self.__installMenu.addAction( + self.tr("Install based on 'code.py'"), self.__installFromCode + ) + self.__installMenu.addSeparator() + self.__installPyAct = self.__installMenu.addAction( + self.tr("Install Python Source") + ) + self.__installPyAct.setCheckable(True) + self.__installPyAct.setChecked(False) + # kind of hack to make this action not hide the menu + # Note: parent menus are hidden nevertheless + self.__installPyAct.toggled.connect(self.__installMenu.show) + + def populateMenu(self, menu): + """ + Public method to populate the 'circup' menu. + + @param menu reference to the menu to be populated + @type QMenu + """ + from .CircupFunctions import patch_circup + + patch_circup() + + act = menu.addAction(self.tr("circup"), self.__aboutCircup) + font = act.font() + font.setBold(True) + act.setFont(font) + menu.addSeparator() + menu.addAction(self.tr("List Outdated Modules"), self.__listOutdatedModules) + menu.addAction(self.tr("Update Modules"), self.__updateModules) + menu.addAction(self.tr("Update All Modules"), self.__updateAllModules) + menu.addSeparator() + menu.addAction(self.tr("Show Available Modules"), self.__showAvailableModules) + menu.addAction(self.tr("Show Installed Modules"), self.__showInstalledModules) + menu.addMenu(self.__installMenu) + menu.addAction(self.tr("Uninstall Modules"), self.__uninstallModules) + menu.addSeparator() + menu.addAction( + self.tr("Generate Requirements ..."), self.__generateRequirements + ) + menu.addSeparator() + menu.addAction(self.tr("Show Bundles"), self.__showBundles) + menu.addAction(self.tr("Show Bundles with Modules"), self.__showBundlesModules) + menu.addSeparator() + menu.addAction(self.tr("Add Bundle"), self.__addBundle) + menu.addAction(self.tr("Remove Bundles"), self.__removeBundle) + + @pyqtSlot() + def __aboutCircup(self): + """ + Private slot to show some info about 'circup'. + """ + version = circup.get_circup_version() + if version is None: + version = self.tr("unknown") + + EricMessageBox.information( + None, + self.tr("About circup"), + self.tr( + """<p><b>circup Version {0}</b></p>""" + """<p><i>circup</i> is a tool to manage and update libraries on a""" + """ CircuitPython device.</p>""", + ).format(version), + ) + + @pyqtSlot() + def installCircup(self): + """ + Public slot to install the 'circup' package via pip. + """ + global circup + + pip = ericApp().getObject("Pip") + pip.installPackages( + ["circup"], interpreter=PythonUtilities.getPythonExecutable() + ) + + circup = importlib.import_module("circup") + circup.logger.setLevel(logging.WARNING) + + @pyqtSlot() + def __showBundles(self, withModules=False): + """ + Private slot to show the available bundles (default and local). + + @param withModules flag indicating to list the modules and their version + (defaults to False) + @type bool (optional) + """ + from .ShowBundlesDialog import ShowBundlesDialog + + with EricOverrideCursor(): + dlg = ShowBundlesDialog(withModules=withModules) + dlg.exec() + + @pyqtSlot() + def __showBundlesModules(self): + """ + Private slot to show the available bundles (default and local) with their + modules. + """ + self.__showBundles(withModules=True) + + @pyqtSlot() + def __addBundle(self): + """ + Private slot to add a bundle to the local bundles list, by "user/repo" github + string. + """ + bundle, ok = QInputDialog.getText( + None, + self.tr("Add Bundle"), + self.tr("Enter Bundle by 'User/Repo' Github String:"), + QLineEdit.EchoMode.Normal, + ) + if ok and bundle: + bundles = circup.get_bundles_local_dict() + modified = False + + # do some cleanup + bundle = re.sub(r"https?://github.com/([^/]+/[^/]+)(/.*)?", r"\1", bundle) + if bundle in bundles: + EricMessageBox.information( + None, + self.tr("Add Bundle"), + self.tr( + """<p>The bundle <b>{0}</b> is already in the list.</p>""" + ).format(bundle), + ) + return + + try: + cBundle = circup.Bundle(bundle) + except ValueError: + EricMessageBox.critical( + None, + self.tr("Add Bundle"), + self.tr( + """<p>The bundle string is invalid, expecting github URL""" + """ or 'user/repository' string.</p>""" + ), + ) + return + + result = requests.head("https://github.com/" + bundle) + if result.status_code == requests.codes.NOT_FOUND: + EricMessageBox.critical( + None, + self.tr("Add Bundle"), + self.tr( + """<p>The bundle string is invalid. The repository doesn't""" + """ exist (error code 404).</p>""" + ), + ) + return + + if not cBundle.validate(): + EricMessageBox.critical( + None, + self.tr("Add Bundle"), + self.tr( + """<p>The bundle string is invalid. Is the repository a valid""" + """circup bundle?</p>""" + ), + ) + return + + # Use the bundle string as the dictionary key for uniqueness + bundles[bundle] = bundle + modified = True + EricMessageBox.information( + None, + self.tr("Add Bundle"), + self.tr("""<p>Added bundle <b>{0}</b> ({1}).</p>""").format( + bundle, cBundle.url + ), + ) + + if modified: + # save the bundles list + circup.save_local_bundles(bundles) + # update and get the new bundle for the first time + circup.get_bundle_versions(circup.get_bundles_list()) + + @pyqtSlot() + def __removeBundle(self): + """ + Private slot to remove one or more bundles from the local bundles list. + """ + localBundles = circup.get_bundles_local_dict() + dlg = EricListSelectionDialog( + sorted(localBundles.keys()), + title=self.tr("Remove Bundles"), + message=self.tr("Select the bundles to be removed:"), + checkBoxSelection=True, + ) + modified = False + if dlg.exec() == QDialog.DialogCode.Accepted: + bundles = dlg.getSelection() + for bundle in bundles: + del localBundles[bundle] + modified = True + + if modified: + circup.save_local_bundles(localBundles) + EricMessageBox.information( + None, + self.tr("Remove Bundles"), + self.tr( + """<p>These bundles were removed from the local bundles list.{0}""" + """</p>""" + ).format("""<ul><li>{0}</li></ul>""".format("</li><li>".join(bundles))), + ) + + @pyqtSlot() + def __listOutdatedModules(self): + """ + Private slot to list the outdated modules of the connected device. + """ + from .ShowOutdatedDialog import ShowOutdatedDialog + + devicePath = self.__device.getWorkspace() + + cpyVersion, board_id = circup.get_circuitpython_version(devicePath) + circup.CPY_VERSION = cpyVersion + + with EricOverrideCursor(): + dlg = ShowOutdatedDialog(devicePath=devicePath) + dlg.exec() + + @pyqtSlot() + def __updateModules(self): + """ + Private slot to update the modules of the connected device. + """ + from .ShowOutdatedDialog import ShowOutdatedDialog + + devicePath = self.__device.getWorkspace() + + cpyVersion, board_id = circup.get_circuitpython_version(devicePath) + circup.CPY_VERSION = cpyVersion + + with EricOverrideCursor(): + dlg = ShowOutdatedDialog(devicePath=devicePath, selectionMode=True) + if dlg.exec() == QDialog.DialogCode.Accepted: + modules = dlg.getSelection() + self.__doUpdateModules(modules) + + @pyqtSlot() + def __updateAllModules(self): + """ + Private slot to update all modules of the connected device. + """ + devicePath = self.__device.getWorkspace() + + cpyVersion, board_id = circup.get_circuitpython_version(devicePath) + circup.CPY_VERSION = cpyVersion + + with EricOverrideCursor(): + modules = [ + m + for m in circup.find_modules(devicePath, circup.get_bundles_list()) + if m.outofdate + ] + if modules: + self.__doUpdateModules(modules) + else: + EricMessageBox.information( + None, + self.tr("Update Modules"), + self.tr("All modules are already up-to-date."), + ) + + def __doUpdateModules(self, modules): + """ + Private method to perform the update of a list of modules. + + @param modules list of modules to be updated + @type circup.Module + """ + updatedModules = [] + for module in modules: + try: + module.update() + updatedModules.append(module.name) + except Exception as ex: + EricMessageBox.critical( + None, + self.tr("Update Modules"), + self.tr( + """<p>There was an error updating <b>{0}</b>.</p>""" + """<p>Error: {1}</p>""" + ).format(module.name, str(ex)), + ) + + if updatedModules: + EricMessageBox.information( + None, + self.tr("Update Modules"), + self.tr( + """<p>These modules were updated on the connected device.{0}</p>""" + ).format( + """<ul><li>{0}</li></ul>""".format("</li><li>".join(updatedModules)) + ), + ) + else: + EricMessageBox.information( + None, + self.tr("Update Modules"), + self.tr("No modules could be updated."), + ) + + @pyqtSlot() + def __showAvailableModules(self): + """ + Private slot to show the available modules. + + These are modules which could be installed on the device. + """ + from .ShowModulesDialog import ShowModulesDialog + + with EricOverrideCursor(): + dlg = ShowModulesDialog() + dlg.exec() + + @pyqtSlot() + def __showInstalledModules(self): + """ + Private slot to show the modules installed on the connected device. + """ + from .ShowInstalledDialog import ShowInstalledDialog + + devicePath = self.__device.getWorkspace() + + with EricOverrideCursor(): + dlg = ShowInstalledDialog(devicePath=devicePath) + dlg.exec() + + @pyqtSlot() + def __installFromAvailable(self): + """ + Private slot to install modules onto the connected device. + """ + from .ShowModulesDialog import ShowModulesDialog + + with EricOverrideCursor(): + dlg = ShowModulesDialog(selectionMode=True) + if dlg.exec() == QDialog.DialogCode.Accepted: + modules = dlg.getSelection() + self.__installModules(modules) + + @pyqtSlot() + def __installRequirements(self): + """ + Private slot to install modules determined by a requirements file. + """ + homeDir = ( + Preferences.getMicroPython("MpyWorkspace") + or Preferences.getMultiProject("Workspace") + or os.path.expanduser("~") + ) + reqFile = EricFileDialog.getOpenFileName( + None, + self.tr("Install Modules"), + homeDir, + self.tr("Text Files (*.txt);;All Files (*)"), + ) + if reqFile: + if os.path.exists(reqFile): + with open(reqFile, "r") as fp: + requirementsText = fp.read() + modules = circup.libraries_from_requirements(requirementsText) + if modules: + self.__installModules(modules) + else: + EricMessageBox.critical( + None, + self.tr("Install Modules"), + self.tr( + """<p>The given requirements file <b>{0}</b> does not""" + """ contain valid modules.</p>""" + ).format(reqFile), + ) + else: + EricMessageBox.critical( + None, + self.tr("Install Modules"), + self.tr( + """<p>The given requirements file <b>{0}</b> does not exist.""" + """</p>""" + ).format(reqFile), + ) + + @pyqtSlot() + def __installFromCode(self): + """ + Private slot to install modules based on the 'code.py' file of the + connected device. + """ + devicePath = self.__device.getWorkspace() + + codeFile = EricFileDialog.getOpenFileName( + None, + self.tr("Install Modules"), + os.path.join(devicePath, "code.py"), + self.tr("Python Files (*.py);;All Files (*)"), + ) + if codeFile: + if os.path.exists(codeFile): + + with EricOverrideCursor(): + availableModules = circup.get_bundle_versions( + circup.get_bundles_list() + ) + moduleNames = {} + for module, metadata in availableModules.items(): + moduleNames[module.replace(".py", "")] = metadata + + modules = circup.libraries_from_imports(codeFile, moduleNames) + if modules: + self.__installModules(modules) + else: + EricMessageBox.critical( + None, + self.tr("Install Modules"), + self.tr( + """<p>The given code file <b>{0}</b> does not""" + """ contain valid import statements or does not import""" + """ external modules.</p>""" + ).format(codeFile), + ) + else: + EricMessageBox.critical( + None, + self.tr("Install Modules"), + self.tr( + """<p>The given code file <b>{0}</b> does not exist.</p>""" + ).format(codeFile), + ) + + def __installModules(self, installs): + """ + Private method to install the given list of modules. + + @param installs list of module names to be installed + @type list of str + """ + devicePath = self.__device.getWorkspace() + + cpyVersion, board_id = circup.get_circuitpython_version(devicePath) + circup.CPY_VERSION = cpyVersion + + with EricOverrideCursor(): + availableModules = circup.get_bundle_versions(circup.get_bundles_list()) + moduleNames = {} + for module, metadata in availableModules.items(): + moduleNames[module.replace(".py", "")] = metadata + toBeInstalled = circup.get_dependencies(installs, mod_names=moduleNames) + deviceModules = circup.get_device_versions(devicePath) + if toBeInstalled is not None: + dependencies = [m for m in toBeInstalled if m not in installs] + ok = EricMessageBox.yesNo( + None, + self.tr("Install Modules"), + self.tr("""<p>Ready to install these modules?{0}{1}</p>""").format( + """<ul><li>{0}</li></ul>""".format( + "</li><li>".join(sorted(installs)) + ), + self.tr("Dependencies:") + + """<ul><li>{0}</li></ul>""".format( + "</li><li>".join(sorted(dependencies)) + ) + if dependencies + else "", + ), + yesDefault=True, + ) + if ok: + installedModules = [] + with EricOverrideCursor(): + for library in toBeInstalled: + success = circup.install_module( + devicePath, + deviceModules, + library, + self.__installPyAct.isChecked(), + moduleNames, + ) + if success: + installedModules.append(library) + + if installedModules: + EricMessageBox.information( + None, + self.tr("Install Modules"), + self.tr( + "<p>Installation complete. These modules were installed" + " successfully.{0}</p>" + ).format( + """<ul><li>{0}</li></ul>""".format( + "</li><li>".join(sorted(installedModules)) + ), + ), + ) + else: + EricMessageBox.information( + None, + self.tr("Install Modules"), + self.tr( + "<p>Installation complete. No modules were installed.</p>" + ), + ) + else: + EricMessageBox.information( + None, + self.tr("Install Modules"), + self.tr("<p>No modules installation is required.</p>"), + ) + + @pyqtSlot() + def __uninstallModules(self): + """ + Private slot to uninstall modules from the connected device. + """ + devicePath = self.__device.getWorkspace() + libraryPath = os.path.join(devicePath, "lib") + + with EricOverrideCursor(): + deviceModules = circup.get_device_versions(devicePath) + modNames = {} + for moduleItem, metadata in deviceModules.items(): + modNames[moduleItem.replace(".py", "").lower()] = metadata + + dlg = EricListSelectionDialog( + sorted(modNames.keys()), + title=self.tr("Uninstall Modules"), + message=self.tr("Select the modules/packages to be uninstalled:"), + checkBoxSelection=True, + ) + if dlg.exec() == QDialog.DialogCode.Accepted: + names = dlg.getSelection() + for name in names: + modulePath = modNames[name]["path"] + if os.path.isdir(modulePath): + target = os.path.basename(os.path.dirname(modulePath)) + targetPath = os.path.join(libraryPath, target) + # Remove the package directory. + shutil.rmtree(targetPath) + else: + target = os.path.basename(modulePath) + targetPath = os.path.join(libraryPath, target) + # Remove the module file + os.remove(targetPath) + + EricMessageBox.information( + None, + self.tr("Uninstall Modules"), + self.tr( + """<p>These modules/packages were uninstalled from the connected""" + """ device.{0}</p>""" + ).format("""<ul><li>{0}</li></ul>""".format("</li><li>".join(names))), + ) + + @pyqtSlot() + def __generateRequirements(self): + """ + Private slot to generate requirements for the connected device. + """ + from .RequirementsDialog import RequirementsDialog + + devicePath = self.__device.getWorkspace() + + cpyVersion, board_id = circup.get_circuitpython_version(devicePath) + circup.CPY_VERSION = cpyVersion + + dlg = RequirementsDialog(devicePath=devicePath) + dlg.exec() + + +def isCircupAvailable(): + """ + Function to check for the availability of 'circup'. + + @return flag indicating the availability of 'circup' + @rtype bool + """ + global circup + + return circup is not None