src/eric7/MicroPython/CircuitPythonUpdater/CircuitPythonUpdaterInterface.py

branch
eric7
changeset 9740
90072e10ae9b
child 9741
901caff48307
--- /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

eric ide

mercurial