src/eric7/CondaInterface/Conda.py

Thu, 24 Apr 2025 12:58:23 +0200

author
Detlev Offenbach <detlev@die-offenbachs.de>
date
Thu, 24 Apr 2025 12:58:23 +0200
branch
eric7
changeset 11233
295366a93bbf
parent 11230
8a15b05eeee3
permissions
-rw-r--r--

Corrected some shortcomings in the conda interface.

# -*- coding: utf-8 -*-

# Copyright (c) 2019 - 2025 Detlev Offenbach <detlev@die-offenbachs.de>
#

"""
Package implementing the conda GUI logic.
"""

import contextlib
import json
import os

from PyQt6.QtCore import QCoreApplication, QObject, QProcess, pyqtSignal, pyqtSlot
from PyQt6.QtWidgets import QDialog

from eric7 import Preferences
from eric7.EricWidgets import EricMessageBox
from eric7.EricWidgets.EricApplication import ericApp
from eric7.SystemUtilities import OSUtilities
from eric7.VirtualEnv.VirtualenvMeta import VirtualenvMetaData
from eric7.VirtualEnv.VirtualenvRegistry import VirtualenvType

from . import condaVersion, isCondaAvailable, rootPrefix
from .CondaExecDialog import CondaExecDialog


class Conda(QObject):
    """
    Class implementing the conda GUI logic.

    @signal condaEnvironmentCreated() emitted to indicate the creation of
        a new environment
    @signal condaEnvironmentRemoved() emitted to indicate the removal of
        an environment
    """

    condaEnvironmentCreated = pyqtSignal()
    condaEnvironmentRemoved = pyqtSignal()

    RootName = QCoreApplication.translate("Conda", "<root>")
    EnvironmentType = "conda"

    def __init__(self, parent=None):
        """
        Constructor

        @param parent parent
        @type QObject
        """
        super().__init__(parent)

        self.__ui = parent
        with contextlib.suppress(AttributeError):
            self.__ui.preferencesChanged.connect(self.__preferencesChanged)

        self.__preferencesChanged()

    @pyqtSlot()
    def __preferencesChanged(self):
        """
        Private slot handling a change of configuration.
        """
        envManager = ericApp().getObject("VirtualEnvManager")
        if isCondaAvailable():
            with contextlib.suppress(KeyError):
                # conda was possibly registered already
                envManager.registerType(
                    VirtualenvType(
                        name=Conda.EnvironmentType,
                        visual_name=self.tr("Anaconda"),
                        createFunc=self.createCondaVirtualEnvironment,
                        deleteFunc=self.deleteCondaVirtualEnvironment,
                        defaultExecPathFunc=self.condaDefaultExecPath,
                    )
                )
        else:
            envManager.unregisterType(name=Conda.EnvironmentType)

    #######################################################################
    ## environment related methods below
    #######################################################################

    def condaDefaultExecPath(self, venvDirectory):
        """
        Public method returning the default PATH prefix for a conda environment.

        @param venvDirectory path of the virtual environment to generate the
            default PATH prefix for
        @type str
        @return default PATH prefix string
        @rtype str
        """
        if OSUtilities.isWindowsPlatform():
            return os.pathsep.join(
                [
                    venvDirectory,
                    os.path.join(venvDirectory, "Scripts"),
                    os.path.join(venvDirectory, "Library", "bin"),
                ]
            )
        else:
            return os.path.join(venvDirectory, "bin")

    def createCondaVirtualEnvironment(self, baseDir=""):
        """
        Public method to create an anaconda/miniconda environment.

        Note: This method is used by the Virtual Environment Manager.

        @param baseDir base directory for the virtual environments (defaults to "")
        @type str (optional)
        """
        from .CondaEnvironmentConfigurationDialog import (
            CondaEnvironmentConfigurationDialog,
        )

        if not isCondaAvailable():
            EricMessageBox.critical(
                self.__ui,
                self.tr("Create Conda Environment"),
                self.tr(
                    "Conda has not been installed or is not configured. Aborting..."
                ),
            )
            return

        dlg = CondaEnvironmentConfigurationDialog(baseDir=baseDir, parent=self.__ui)
        if dlg.exec() == QDialog.DialogCode.Accepted:
            resultDict = dlg.getData()
            # now create the conda environment
            ok, prefix, interpreter = self.createCondaEnvironment(
                resultDict["arguments"]
            )
            if ok and "--dry-run" not in resultDict["arguments"]:
                ericApp().getObject("VirtualEnvManager").addVirtualEnv(
                    VirtualenvMetaData(
                        name=resultDict["logicalName"],
                        path=prefix,
                        interpreter=interpreter,
                        environment_type=Conda.EnvironmentType,
                    )
                )

    def deleteCondaVirtualEnvironment(self, venvMetaData):
        """
        Public method to delete a given anaconda/miniconda environment from disk.

        Note: This method is used by the Virtual Environment Manager.

        @param venvMetaData virtual environment meta data structure
        @type VirtualenvMetaData
        @return flag indicating success
        @rtype bool
        """
        return self.removeCondaEnvironment(prefix=venvMetaData.path)

    def createCondaEnvironment(self, arguments):
        """
        Public method to create a conda environment.

        @param arguments list of command line arguments
        @type list of str
        @return tuple containing a flag indicating success, the directory of
            the created environment (aka. prefix) and the corresponding Python
            interpreter
        @rtype tuple of (bool, str, str)
        """
        args = ["create", "--json", "--yes"] + arguments

        dlg = CondaExecDialog("create", parent=self.__ui)
        dlg.start(args)
        dlg.exec()
        ok, resultDict = dlg.getResult()

        if ok:
            if "actions" in resultDict and "PREFIX" in resultDict["actions"]:
                prefix = resultDict["actions"]["PREFIX"]
            else:
                prefix = resultDict.get("prefix", resultDict.get("dst_prefix", ""))

            # determine Python executable
            if prefix:
                pathPrefixes = [prefix, rootPrefix()]
            else:
                pathPrefixes = [rootPrefix()]
            for pathPrefix in pathPrefixes:
                python = (
                    os.path.join(pathPrefix, "python.exe")
                    if OSUtilities.isWindowsPlatform()
                    else os.path.join(pathPrefix, "bin", "python")
                )
                if os.path.exists(python):
                    break
            else:
                python = ""

            self.condaEnvironmentCreated.emit()
            return True, prefix, python
        else:
            return False, "", ""

    def removeCondaEnvironment(self, name="", prefix=""):
        """
        Public method to remove a conda environment.

        @param name name of the environment
        @type str
        @param prefix prefix of the environment
        @type str
        @return flag indicating success
        @rtype bool
        @exception RuntimeError raised to indicate an error in parameters

        Note: only one of name or prefix must be given.
        """
        if name and prefix:
            raise RuntimeError("Only one of 'name' or 'prefix' must be given.")

        if not name and not prefix:
            raise RuntimeError("One of 'name' or 'prefix' must be given.")

        args = [
            "remove",
            "--json",
            "--quiet",
            "--all",
        ]
        if name:
            args.extend(["--name", name])
        elif prefix:
            args.extend(["--prefix", prefix])

        exe = Preferences.getConda("CondaExecutable")
        if not exe:
            exe = "conda"

        proc = QProcess()
        proc.start(exe, args)
        if not proc.waitForStarted(15000):
            EricMessageBox.critical(
                self.__ui,
                self.tr("conda remove"),
                self.tr("""The conda executable could not be started."""),
            )
            return False
        else:
            proc.waitForFinished(15000)
            output = str(
                proc.readAllStandardOutput(),
                Preferences.getSystem("IOEncoding"),
                "replace",
            ).strip()
            try:
                jsonDict = json.loads(output)
            except Exception:
                EricMessageBox.critical(
                    self.__ui,
                    self.tr("conda remove"),
                    self.tr("""The conda executable returned invalid data."""),
                )
                return False

            if "error" in jsonDict:
                EricMessageBox.critical(
                    self.__ui,
                    self.tr("conda remove"),
                    self.tr(
                        "<p>The conda executable returned an error.</p><p>{0}</p>"
                    ).format(jsonDict["message"]),
                )
                return False

            if jsonDict["success"]:
                self.condaEnvironmentRemoved.emit()

            return jsonDict["success"]

        return False

    def getCondaEnvironmentsList(self):
        """
        Public method to get a list of all Conda environments.

        @return list of tuples containing the environment name and prefix
        @rtype list of tuples of (str, str)
        """
        exe = Preferences.getConda("CondaExecutable")
        if not exe:
            exe = "conda"

        environmentsList = []

        proc = QProcess()
        proc.start(exe, ["info", "--json"])
        if proc.waitForStarted(15000) and proc.waitForFinished(15000):
            output = str(
                proc.readAllStandardOutput(),
                Preferences.getSystem("IOEncoding"),
                "replace",
            ).strip()
            try:
                jsonDict = json.loads(output)
            except Exception:
                jsonDict = {}

            if "envs" in jsonDict:
                for prefix in jsonDict["envs"][:]:
                    if prefix == jsonDict["root_prefix"]:
                        if not jsonDict["root_writable"]:
                            # root prefix is listed but not writable
                            continue
                        name = self.RootName
                    else:
                        name = os.path.basename(prefix)

                    environmentsList.append((name, prefix))

        return environmentsList

    #######################################################################
    ## package related methods below
    #######################################################################

    def getInstalledPackages(self, name="", prefix=""):
        """
        Public method to get a list of installed packages of a conda
        environment.

        @param name name of the environment
        @type str
        @param prefix prefix of the environment
        @type str
        @return list of installed packages. Each entry is a tuple containing
            the package name, version and build.
        @rtype list of tuples of (str, str, str)
        @exception RuntimeError raised to indicate an error in parameters

        Note: only one of name or prefix must be given.
        """
        if name and prefix:
            raise RuntimeError("Only one of 'name' or 'prefix' must be given.")

        if not name and not prefix:
            raise RuntimeError("One of 'name' or 'prefix' must be given.")

        args = [
            "list",
            "--json",
        ]
        if name:
            args.extend(["--name", name])
        elif prefix:
            args.extend(["--prefix", prefix])

        exe = Preferences.getConda("CondaExecutable")
        if not exe:
            exe = "conda"

        packages = []

        proc = QProcess()
        proc.start(exe, args)
        if proc.waitForStarted(15000) and proc.waitForFinished(30000):
            output = str(
                proc.readAllStandardOutput(),
                Preferences.getSystem("IOEncoding"),
                "replace",
            ).strip()
            try:
                jsonList = json.loads(output)
            except Exception:
                jsonList = []

            for package in jsonList:
                if isinstance(package, dict):
                    packages.append(
                        (package["name"], package["version"], package["build_string"])
                    )
                else:
                    parts = package.rsplit("-", 2)
                    while len(parts) < 3:
                        parts.append("")
                    packages.append(tuple(parts))

        return packages

    def getUpdateablePackages(self, name="", prefix=""):
        """
        Public method to get a list of updateable packages of a conda
        environment.

        @param name name of the environment
        @type str
        @param prefix prefix of the environment
        @type str
        @return list of installed packages. Each entry is a tuple containing
            the package name, version and build.
        @rtype list of tuples of (str, str, str)
        @exception RuntimeError raised to indicate an error in parameters

        Note: only one of name or prefix must be given.
        """
        if name and prefix:
            raise RuntimeError("Only one of 'name' or 'prefix' must be given.")

        if not name and not prefix:
            raise RuntimeError("One of 'name' or 'prefix' must be given.")

        args = [
            "update",
            "--json",
            "--quiet",
            "--all",
            "--dry-run",
        ]
        if name:
            args.extend(["--name", name])
        elif prefix:
            args.extend(["--prefix", prefix])

        exe = Preferences.getConda("CondaExecutable")
        if not exe:
            exe = "conda"

        packages = []

        proc = QProcess()
        proc.start(exe, args)
        if proc.waitForStarted(15000) and proc.waitForFinished(30000):
            output = str(
                proc.readAllStandardOutput(),
                Preferences.getSystem("IOEncoding"),
                "replace",
            ).strip()
            try:
                jsonDict = json.loads(output)
            except Exception:
                jsonDict = {}

            if "actions" in jsonDict and "LINK" in jsonDict["actions"]:
                for linkEntry in jsonDict["actions"]["LINK"]:
                    if isinstance(linkEntry, dict):
                        packages.append(
                            (
                                linkEntry["name"],
                                linkEntry["version"],
                                linkEntry["build_string"],
                            )
                        )
                    else:
                        package = linkEntry.split()[0]
                        parts = package.rsplit("-", 2)
                        while len(parts) < 3:
                            parts.append("")
                        packages.append(tuple(parts))

        return packages

    def updatePackages(self, packages, name="", prefix=""):
        """
        Public method to update packages of a conda environment.

        @param packages list of package names to be updated
        @type list of str
        @param name name of the environment
        @type str
        @param prefix prefix of the environment
        @type str
        @return flag indicating success
        @rtype bool
        @exception RuntimeError raised to indicate an error in parameters

        Note: only one of name or prefix must be given.
        """
        if name and prefix:
            raise RuntimeError("Only one of 'name' or 'prefix' must be given.")

        if not name and not prefix:
            raise RuntimeError("One of 'name' or 'prefix' must be given.")

        if packages:
            args = [
                "update",
                "--json",
                "--yes",
            ]
            if name:
                args.extend(["--name", name])
            elif prefix:
                args.extend(["--prefix", prefix])
            args.extend(packages)

            dlg = CondaExecDialog("update", parent=self.__ui)
            dlg.start(args)
            dlg.exec()
            ok, _ = dlg.getResult()
        else:
            ok = False

        return ok

    def updateAllPackages(self, name="", prefix=""):
        """
        Public method to update all packages of a conda environment.

        @param name name of the environment
        @type str
        @param prefix prefix of the environment
        @type str
        @return flag indicating success
        @rtype bool
        @exception RuntimeError raised to indicate an error in parameters

        Note: only one of name or prefix must be given.
        """
        if name and prefix:
            raise RuntimeError("Only one of 'name' or 'prefix' must be given.")

        if not name and not prefix:
            raise RuntimeError("One of 'name' or 'prefix' must be given.")

        args = ["update", "--json", "--yes", "--all"]
        if name:
            args.extend(["--name", name])
        elif prefix:
            args.extend(["--prefix", prefix])

        dlg = CondaExecDialog("update", parent=self.__ui)
        dlg.start(args)
        dlg.exec()
        ok, _ = dlg.getResult()

        return ok

    def installPackages(self, packages, name="", prefix=""):
        """
        Public method to install packages into a conda environment.

        @param packages list of package names to be installed
        @type list of str
        @param name name of the environment
        @type str
        @param prefix prefix of the environment
        @type str
        @return flag indicating success
        @rtype bool
        @exception RuntimeError raised to indicate an error in parameters

        Note: only one of name or prefix must be given.
        """
        if name and prefix:
            raise RuntimeError("Only one of 'name' or 'prefix' must be given.")

        if not name and not prefix:
            raise RuntimeError("One of 'name' or 'prefix' must be given.")

        if packages:
            args = [
                "install",
                "--json",
                "--yes",
            ]
            if name:
                args.extend(["--name", name])
            elif prefix:
                args.extend(["--prefix", prefix])
            args.extend(packages)

            dlg = CondaExecDialog("install", parent=self.__ui)
            dlg.start(args)
            dlg.exec()
            ok, _ = dlg.getResult()
        else:
            ok = False

        return ok

    def uninstallPackages(self, packages, name="", prefix=""):
        """
        Public method to uninstall packages of a conda environment (including
        all no longer needed dependencies).

        @param packages list of package names to be uninstalled
        @type list of str
        @param name name of the environment
        @type str
        @param prefix prefix of the environment
        @type str
        @return flag indicating success
        @rtype bool
        @exception RuntimeError raised to indicate an error in parameters

        Note: only one of name or prefix must be given.
        """
        from eric7.UI.DeleteFilesConfirmationDialog import DeleteFilesConfirmationDialog

        if name and prefix:
            raise RuntimeError("Only one of 'name' or 'prefix' must be given.")

        if not name and not prefix:
            raise RuntimeError("One of 'name' or 'prefix' must be given.")

        if packages:
            dlg = DeleteFilesConfirmationDialog(
                self.__ui,
                self.tr("Uninstall Packages"),
                self.tr(
                    "Do you really want to uninstall these packages and"
                    " their dependencies?"
                ),
                packages,
            )
            if dlg.exec() == QDialog.DialogCode.Accepted:
                args = [
                    "remove",
                    "--json",
                    "--yes",
                ]
                if condaVersion() >= (4, 4, 0):
                    args.append(
                        "--prune",
                    )
                if name:
                    args.extend(["--name", name])
                elif prefix:
                    args.extend(["--prefix", prefix])
                args.extend(packages)

                dlg = CondaExecDialog("remove", parent=self.__ui)
                dlg.start(args)
                dlg.exec()
                ok, _ = dlg.getResult()
            else:
                ok = False
        else:
            ok = False

        return ok

    def searchPackages(
        self,
        pattern,
        fullNameOnly=False,
        packageSpec=False,
        platform="",
        name="",
        prefix="",
    ):
        """
        Public method to search for a package pattern of a conda environment.

        @param pattern package search pattern
        @type str
        @param fullNameOnly flag indicating to search for full names only
        @type bool
        @param packageSpec flag indicating to search a package specification
        @type bool
        @param platform type of platform to be searched for
        @type str
        @param name name of the environment
        @type str
        @param prefix prefix of the environment
        @type str
        @return flag indicating success and a dictionary with package name as
            key and list of dictionaries containing detailed data for the found
            packages as values
        @rtype tuple of (bool, dict of list of dict)
        @exception RuntimeError raised to indicate an error in parameters

        Note: only one of name or prefix must be given.
        """
        if name and prefix:
            raise RuntimeError("Only one of 'name' or 'prefix' must be given.")

        args = [
            "search",
            "--json",
        ]
        if fullNameOnly:
            args.append("--full-name")
        if packageSpec:
            args.append("--spec")
        if platform:
            args.extend(["--platform", platform])
        if name:
            args.extend(["--name", name])
        elif prefix:
            args.extend(["--prefix", prefix])
        args.append(pattern)

        exe = Preferences.getConda("CondaExecutable")
        if not exe:
            exe = "conda"

        packages = {}
        ok = False

        proc = QProcess()
        proc.start(exe, args)
        if proc.waitForStarted(15000) and proc.waitForFinished(30000):
            output = str(
                proc.readAllStandardOutput(),
                Preferences.getSystem("IOEncoding"),
                "replace",
            ).strip()
            with contextlib.suppress(json.JSONDecodeError):
                packages = json.loads(output)
                ok = "error" not in packages

        return ok, packages

    #######################################################################
    ## special methods below
    #######################################################################

    def updateConda(self):
        """
        Public method to update conda itself.

        @return flag indicating success
        @rtype bool
        """
        args = [
            "update",
            "--json",
            "--yes",
            "--name",
            "base",
            "--channel",
            "defaults",
            "conda",
        ]

        dlg = CondaExecDialog("update", parent=self.__ui)
        dlg.start(args)
        dlg.exec()
        ok, _ = dlg.getResult()

        return ok

    def writeDefaultConfiguration(self):
        """
        Public method to create a conda configuration with default values.
        """
        args = ["config", "--write-default", "--quiet"]

        exe = Preferences.getConda("CondaExecutable")
        if not exe:
            exe = "conda"

        proc = QProcess()
        proc.start(exe, args)
        proc.waitForStarted(15000)
        proc.waitForFinished(30000)

    def getCondaInformation(self):
        """
        Public method to get a dictionary containing information about conda.

        @return dictionary containing information about conda
        @rtype dict
        """
        exe = Preferences.getConda("CondaExecutable")
        if not exe:
            exe = "conda"

        infoDict = {}

        proc = QProcess()
        proc.start(exe, ["info", "--json"])
        if proc.waitForStarted(15000) and proc.waitForFinished(30000):
            output = str(
                proc.readAllStandardOutput(),
                Preferences.getSystem("IOEncoding"),
                "replace",
            ).strip()
            try:
                infoDict = json.loads(output)
            except Exception:
                infoDict = {}

        return infoDict

    def runProcess(self, args):
        """
        Public method to execute the conda with the given arguments.

        The conda executable is called with the given arguments and
        waited for its end.

        @param args list of command line arguments
        @type list of str
        @return tuple containing a flag indicating success and the output
            of the process
        @rtype tuple of (bool, str)
        """
        exe = Preferences.getConda("CondaExecutable")
        if not exe:
            exe = "conda"

        process = QProcess()
        process.start(exe, args)
        procStarted = process.waitForStarted(15000)
        if procStarted:
            finished = process.waitForFinished(30000)
            if finished:
                if process.exitCode() == 0:
                    output = str(
                        process.readAllStandardOutput(),
                        Preferences.getSystem("IOEncoding"),
                        "replace",
                    ).strip()
                    return True, output
                else:
                    return (
                        False,
                        self.tr("conda exited with an error ({0}).").format(
                            process.exitCode()
                        ),
                    )
            else:
                process.terminate()
                process.waitForFinished(2000)
                process.kill()
                process.waitForFinished(3000)
                return False, self.tr("conda did not finish within 3 seconds.")

        return False, self.tr("conda could not be started.")

    def cleanConda(self, cleanAction):
        """
        Public method to update conda itself.

        @param cleanAction cleaning action to be performed (must be one of
            the command line parameters without '--')
        @type str
        """
        args = [
            "clean",
            "--yes",
            "--{0}".format(cleanAction),
        ]

        dlg = CondaExecDialog("clean", parent=self.__ui)
        dlg.start(args)
        dlg.exec()

eric ide

mercurial