Skip to content

Commit

Permalink
[feature][needs-docs] Plugin dependencies
Browse files Browse the repository at this point in the history
Implementation of QEP 132:

Manage python cross-plugins dependencies

A new optional metadata entry will be added to metadata.txt: plugin_dependencies
The metadata will contain a comma separated list of plugin names, with a format similar
of the one used by pip, with optional version.
After a successful plugin installation, if the plugin has any unsatisfied dependency,
a dialog will pop-up with the list of unmet dependencies and the user
will be able to choose if she wants to install or upgrade the
dependencies or ignore them.

Example metadata:

plugin_dependencies = QuickMapServices==0.19.10.1,QuickWKT

Funded by GISCE-TI S.L.
  • Loading branch information
elpaso committed Mar 15, 2019
1 parent 37faa0d commit 1622d73
Show file tree
Hide file tree
Showing 7 changed files with 383 additions and 6 deletions.
3 changes: 3 additions & 0 deletions python/pyplugin_installer/CMakeLists.txt
Expand Up @@ -9,6 +9,8 @@ SET(PY_PLUGININSTALLER_FILES
qgsplugininstallerpluginerrordialog.py
qgsplugininstallerfetchingdialog.py
qgsplugininstallerrepositorydialog.py
qgsplugindependencies.py
qgsplugindependenciesdialog.py
unzip.py
version_compare.py
)
Expand All @@ -21,6 +23,7 @@ PYQT_WRAP_UI(PYUI_FILES
qgsplugininstallerinstallingbase.ui
qgsplugininstallerpluginerrorbase.ui
qgsplugininstallerrepositorybase.ui
qgsplugindependenciesdialogbase.ui
)

ADD_CUSTOM_TARGET(pyplugin-installer ALL DEPENDS ${PYUI_FILES})
Expand Down
43 changes: 41 additions & 2 deletions python/pyplugin_installer/installer.py
Expand Up @@ -35,14 +35,16 @@
from qgis.core import Qgis, QgsApplication, QgsNetworkAccessManager, QgsSettings, QgsNetworkRequestParameters
from qgis.gui import QgsMessageBar, QgsPasswordLineEdit
from qgis.utils import (iface, startPlugin, unloadPlugin, loadPlugin,
reloadPlugin, updateAvailablePlugins)
reloadPlugin, updateAvailablePlugins, plugins_metadata_parser)
from .installer_data import (repositories, plugins, officialRepo,
settingsGroup, reposGroup, removeDir)
from .qgsplugininstallerinstallingdialog import QgsPluginInstallerInstallingDialog
from .qgsplugininstallerpluginerrordialog import QgsPluginInstallerPluginErrorDialog
from .qgsplugininstallerfetchingdialog import QgsPluginInstallerFetchingDialog
from .qgsplugininstallerrepositorydialog import QgsPluginInstallerRepositoryDialog
from .unzip import unzip
from .qgsplugindependencies import find_dependencies
from .qgsplugindependenciesdialog import QgsPluginDependenciesDialog


# public instances:
Expand Down Expand Up @@ -128,7 +130,7 @@ def fetchAvailablePlugins(self, reloadMode):

QApplication.restoreOverrideCursor()

# display error messages for every unavailable reposioty, unless Shift pressed nor all repositories are unavailable
# display error messages for every unavailable repository, unless Shift pressed nor all repositories are unavailable
keepQuiet = QgsApplication.keyboardModifiers() == Qt.KeyboardModifiers(Qt.ShiftModifier)
if repositories.allUnavailable() and repositories.allUnavailable() != repositories.allEnabled():
for key in repositories.allUnavailable():
Expand Down Expand Up @@ -230,6 +232,7 @@ def exportPluginsToManager(self):
"downloads": plugin["downloads"],
"average_vote": plugin["average_vote"],
"rating_votes": plugin["rating_votes"],
"plugin_dependencies": plugin.get("plugin_dependencies", None),
"pythonic": "true"
})
iface.pluginManagerInterface().reloadModel()
Expand Down Expand Up @@ -309,6 +312,7 @@ def installPlugin(self, key, quiet=False):
QApplication.setOverrideCursor(Qt.WaitCursor)
# update the list of plugins in plugin handling routines
updateAvailablePlugins()
self.processDependencies(plugin["id"])
# try to load the plugin
loadPlugin(plugin["id"])
plugins.getAllInstalled()
Expand Down Expand Up @@ -417,6 +421,11 @@ def uninstallPlugin(self, key, quiet=False):
exec("del sys.modules[%s]" % plugin["id"])
except:
pass
try:
exec("del plugins_metadata_parser[%s]" % plugin["id"])
except:
pass

plugins.getAllInstalled()
plugins.rebuild()
self.exportPluginsToManager()
Expand Down Expand Up @@ -599,6 +608,7 @@ def installFromZipFile(self, filePath):

if success:
updateAvailablePlugins()
self.processDependencies(pluginName)
loadPlugin(pluginName)
plugins.getAllInstalled()
plugins.rebuild()
Expand All @@ -621,3 +631,32 @@ def installFromZipFile(self, filePath):

level = Qgis.Info if success else Qgis.Critical
iface.pluginManagerInterface().pushMessage(msg, level)

def processDependencies(self, plugin_id):
"""Processes plugin dependencies
:param plugin_id: plugin id
:type plugin_id: str
"""

to_install, to_upgrade, not_found = find_dependencies(plugin_id)
if to_install or to_upgrade or not_found:
dlg = QgsPluginDependenciesDialog(plugin_id, to_install, to_upgrade, not_found)
if dlg.exec_() == QgsPluginDependenciesDialog.Accepted:
actions = dlg.actions()
for dependency_plugin_id, action in actions.items():
try:
self.installPlugin(dependency_plugin_id)
if action == 'install':
iface.pluginManagerInterface().pushMessage(self.tr("Plugin dependency <b>%s</b> successfully installed") %
dependency_plugin_id, Qgis.Info)
else:
iface.pluginManagerInterface().pushMessage(self.tr("Plugin dependency <b>%s</b> successfully upgraded") %
dependency_plugin_id, Qgis.Info)
except Exception as ex:
if action == 'install':
iface.pluginManagerInterface().pushMessage(self.tr("Error installing plugin dependency <b>%s</b>: %s") %
(dependency_plugin_id, ex), Qgis.Warning)
else:
iface.pluginManagerInterface().pushMessage(self.tr("Error upgrading plugin dependency <b>%s</b>: %s") %
(dependency_plugin_id, ex), Qgis.Warning)
12 changes: 8 additions & 4 deletions python/pyplugin_installer/installer_data.py
Expand Up @@ -85,6 +85,7 @@
"downloads" unicode, # number of downloads
"average_vote" unicode, # average vote
"rating_votes" unicode, # number of votes
"plugin_dependencies" unicode, # PIP-style comma separated list of plugin dependencies
}}
"""

Expand Down Expand Up @@ -446,7 +447,8 @@ def xmlDownloaded(self):
"version_installed": "",
"zip_repository": reposName,
"library": "",
"readonly": False
"readonly": False,
"plugin_dependencies": pluginNodes.item(i).firstChildElement("plugin_dependencies").text().strip(),
}
qgisMinimumVersion = pluginNodes.item(i).firstChildElement("qgis_minimum_version").text().strip()
if not qgisMinimumVersion:
Expand Down Expand Up @@ -674,7 +676,9 @@ def pluginMetadata(fct):
"status": "orphan", # Will be overwritten, if any available version found.
"error": error,
"error_details": errorDetails,
"readonly": readOnly}
"readonly": readOnly,
"plugin_dependencies": pluginMetadata("plugin_dependencies"),
}
return plugin

# ----------------------------------------- #
Expand Down Expand Up @@ -746,9 +750,9 @@ def rebuild(self):
# other remote metadata is preferred:
for attrib in ["name", "plugin_id", "description", "about", "category", "tags", "changelog", "author_name", "author_email", "homepage",
"tracker", "code_repository", "experimental", "deprecated", "version_available", "zip_repository",
"download_url", "filename", "downloads", "average_vote", "rating_votes", "trusted"]:
"download_url", "filename", "downloads", "average_vote", "rating_votes", "trusted", "plugin_dependencies"]:
if attrib not in translatableAttributes or attrib == "name": # include name!
if plugin[attrib]:
if plugin.get(attrib, False):
self.mPlugins[key][attrib] = plugin[attrib]
# set status
#
Expand Down
122 changes: 122 additions & 0 deletions python/pyplugin_installer/qgsplugindependencies.py
@@ -0,0 +1,122 @@
# coding=utf-8
"""Parse plugin metadata for plugin_dependencies and install/update
required plugins
.. note:: This program is free software; you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation; either version 2 of the License, or
(at your option) any later version.
"""

__author__ = 'elpaso@itopen.it'
__date__ = '2018-05-29'
__copyright__ = 'Copyright 2018, GISCE-TI S.L.'

from configparser import NoOptionError, NoSectionError
from .version_compare import compareVersions
from . import installer as plugin_installer


def __plugin_name_map(plugin_data_values):
return {
plugin['name']: plugin['id']
for plugin in plugin_data_values
}


def __get_plugin_deps(plugin_id):

result = {}
from qgis.utils import plugins_metadata_parser
parser = plugins_metadata_parser[plugin_id]
try:
plugin_deps = parser.get('general', 'plugin_dependencies')
except (NoOptionError, NoSectionError):
return result

for dep in plugin_deps.split(','):
if dep.find('==') > 0:
name, version_required = dep.split('==')
else:
name = dep
version_required = None
result[name] = version_required
return result


def find_dependencies(plugin_id, plugin_data=None, plugin_deps=None, installed_plugins=None):
"""Finds the plugin dependencies and checks if they can be installed or upgraded
:param plugin_id: plugin id
:type plugin_id: str
:param plugin_data: for testing only: dictionary of plugin data from the repo, defaults to None
:param plugin_data: dict, optional
:param plugin_deps: for testing only: dict of plugin id -> version_required, parsed from metadata value for "plugin_dependencies", defaults to None
:param plugin_deps: dict, optional
:param installed_plugins: for testing only: dict of plugin id -> version_installed
:param installed_plugins: dict, optional
:return: result dictionaries keyed by plugin name with: to_install, to_upgrade, not_found
:rtype: tuple of dicts
"""

to_install = {}
to_upgrade = {}
not_found = {}

if plugin_deps is None:
plugin_deps = __get_plugin_deps(plugin_id)

if installed_plugins is None:
from qgis.utils import plugins_metadata_parser
installed_plugins = {plugins_metadata_parser[k].get('general', 'name'): plugins_metadata_parser[k].get('general', 'version') for k, v in plugins_metadata_parser.items()}

if plugin_data is None:
plugin_data = plugin_installer.plugins.all()

plugins_map = __plugin_name_map(plugin_data.values())

# Review all dependencies
for name, version_required in plugin_deps.items():
try:
p_id = plugins_map[name]
except KeyError:
not_found.update({name: {
'id': None,
'version_installed': None,
'version_required': None,
'version_available': None,
'action': None,
'error': 'missing_id'
}})
continue

affected_plugin = dict({
"id": p_id,
# "version_installed": installed_plugins.get(p_id, {}).get('installed_plugins', None),
"version_installed": installed_plugins.get(name, None),
"version_required": version_required,
"version_available": plugin_data[p_id].get('version_available', None),
"action": None,
})

# Install is needed
if name not in installed_plugins:
affected_plugin['action'] = 'install'
destination_list = to_install
# Upgrade is needed
elif version_required is not None and compareVersions(installed_plugins[name], version_required) == 2:
affected_plugin['action'] = 'upgrade'
destination_list = to_upgrade
# TODO @elpaso: review installed but not activated
# No action is needed
else:
continue

if affected_plugin['version_required'] == affected_plugin['version_available'] or affected_plugin['version_required'] is None:
destination_list.update({name: affected_plugin})
else:
affected_plugin['error'] = 'unavailable {}'.format(affected_plugin['action'])
not_found.update({name: affected_plugin})

return to_install, to_upgrade, not_found
112 changes: 112 additions & 0 deletions python/pyplugin_installer/qgsplugindependenciesdialog.py
@@ -0,0 +1,112 @@
# coding=utf-8
"""Plugin update/install dialog
.. note:: This program is free software; you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation; either version 2 of the License, or
(at your option) any later version.
"""

__author__ = 'elpaso@itopen.it'
__date__ = '2018-09-19'
__copyright__ = 'Copyright 2018, GISCE-TI S.L.'


import os

from qgis.PyQt import QtWidgets, QtCore
from .ui_qgsplugindependenciesdialogbase import Ui_QgsPluginDependenciesDialogBase
from qgis.utils import iface


class QgsPluginDependenciesDialog(QtWidgets.QDialog, Ui_QgsPluginDependenciesDialogBase):
"""A dialog that shows plugin dependencies and offers a way to install or upgrade the
dependencies.
"""

def __init__(self, plugin_name, to_install, to_upgrade, not_found, parent=None):
"""Creates the dependencies dialog
:param plugin_name: the name of the parent plugin
:type plugin_name: str
:param to_install: list of plugin IDs that needs to be installed
:type to_install: list
:param to_upgrade: list of plugin IDs that needs to be upgraded
:type to_upgrade: list
:param not_found: list of plugin IDs that are not found (unvailable)
:type not_found: list
:param parent: parent object, defaults to None
:param parent: QWidget, optional
"""

super().__init__(parent)
self.setupUi(self)
self.setWindowTitle(self.tr("Plugin Dependencies Manager"))
self.mPluginDependenciesLabel.setText(self.tr("Plugin dependencies for <b>%s</b>") % plugin_name)
self.setStyleSheet("QTableView { padding: 20px;}")
# Name, Version Installed, Version Required, Version Available, Action Checkbox
self.pluginList.setColumnCount(5)
self.pluginList.setHorizontalHeaderLabels([self.tr('Name'), self.tr('Installed'), self.tr('Required'), self.tr('Available'), self.tr('Action')])
self.pluginList.setRowCount(len(not_found) + len(to_install) + len(to_upgrade))
self.__actions = {}

def _display(txt):
if txt is None:
return ""
return txt

def _make_row(data, i, name):
widget = QtWidgets.QLabel("<b>%s</b>" % name)
widget.p_id = data['id']
widget.action = data['action']
self.pluginList.setCellWidget(i, 0, widget)
widget = QtWidgets.QTableWidgetItem(_display(data['version_installed']))
widget.setTextAlignment(QtCore.Qt.AlignHCenter | QtCore.Qt.AlignVCenter)
self.pluginList.setItem(i, 1, widget)
widget = QtWidgets.QTableWidgetItem(_display(data['version_required']))
widget.setTextAlignment(QtCore.Qt.AlignHCenter | QtCore.Qt.AlignVCenter)
self.pluginList.setItem(i, 2, widget)
widget = QtWidgets.QTableWidgetItem(_display(data['version_available']))
widget.setTextAlignment(QtCore.Qt.AlignHCenter | QtCore.Qt.AlignVCenter)
self.pluginList.setItem(i, 3, widget)

i = 0
for name, data in to_install.items():
_make_row(data, i, name)
widget = QtWidgets.QCheckBox(self.tr("Install"))
widget.setChecked(True)
self.pluginList.setCellWidget(i, 4, widget)
i += 1

for name, data in to_upgrade.items():
_make_row(data, i, name)
widget = QtWidgets.QCheckBox(self.tr("Upgrade"))
widget.setChecked(True)
self.pluginList.setCellWidget(i, 4, widget)
i += 1

for name, data in not_found.items():
_make_row(data, i, name)
widget = QtWidgets.QLabel(self.tr("Fix manually"))
self.pluginList.setCellWidget(i, 4, widget)
i += 1

def actions(self):
"""Returns the list of actions
:return: dict of actions
:rtype: dict
"""

return self.__actions

def accept(self):
self.__actions = {}
for i in range(self.pluginList.rowCount()):
try:
if self.pluginList.cellWidget(i, 4).isChecked():
self.__actions[self.pluginList.cellWidget(i, 0).p_id] = self.pluginList.cellWidget(i, 0).action
except:
pass
super().accept()

0 comments on commit 1622d73

Please sign in to comment.