Skip to content

Commit 1622d73

Browse files
committedMar 15, 2019
[feature][needs-docs] Plugin dependencies
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.
1 parent 37faa0d commit 1622d73

File tree

7 files changed

+383
-6
lines changed

7 files changed

+383
-6
lines changed
 

‎python/pyplugin_installer/CMakeLists.txt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ SET(PY_PLUGININSTALLER_FILES
99
qgsplugininstallerpluginerrordialog.py
1010
qgsplugininstallerfetchingdialog.py
1111
qgsplugininstallerrepositorydialog.py
12+
qgsplugindependencies.py
13+
qgsplugindependenciesdialog.py
1214
unzip.py
1315
version_compare.py
1416
)
@@ -21,6 +23,7 @@ PYQT_WRAP_UI(PYUI_FILES
2123
qgsplugininstallerinstallingbase.ui
2224
qgsplugininstallerpluginerrorbase.ui
2325
qgsplugininstallerrepositorybase.ui
26+
qgsplugindependenciesdialogbase.ui
2427
)
2528

2629
ADD_CUSTOM_TARGET(pyplugin-installer ALL DEPENDS ${PYUI_FILES})

‎python/pyplugin_installer/installer.py

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,14 +35,16 @@
3535
from qgis.core import Qgis, QgsApplication, QgsNetworkAccessManager, QgsSettings, QgsNetworkRequestParameters
3636
from qgis.gui import QgsMessageBar, QgsPasswordLineEdit
3737
from qgis.utils import (iface, startPlugin, unloadPlugin, loadPlugin,
38-
reloadPlugin, updateAvailablePlugins)
38+
reloadPlugin, updateAvailablePlugins, plugins_metadata_parser)
3939
from .installer_data import (repositories, plugins, officialRepo,
4040
settingsGroup, reposGroup, removeDir)
4141
from .qgsplugininstallerinstallingdialog import QgsPluginInstallerInstallingDialog
4242
from .qgsplugininstallerpluginerrordialog import QgsPluginInstallerPluginErrorDialog
4343
from .qgsplugininstallerfetchingdialog import QgsPluginInstallerFetchingDialog
4444
from .qgsplugininstallerrepositorydialog import QgsPluginInstallerRepositoryDialog
4545
from .unzip import unzip
46+
from .qgsplugindependencies import find_dependencies
47+
from .qgsplugindependenciesdialog import QgsPluginDependenciesDialog
4648

4749

4850
# public instances:
@@ -128,7 +130,7 @@ def fetchAvailablePlugins(self, reloadMode):
128130

129131
QApplication.restoreOverrideCursor()
130132

131-
# display error messages for every unavailable reposioty, unless Shift pressed nor all repositories are unavailable
133+
# display error messages for every unavailable repository, unless Shift pressed nor all repositories are unavailable
132134
keepQuiet = QgsApplication.keyboardModifiers() == Qt.KeyboardModifiers(Qt.ShiftModifier)
133135
if repositories.allUnavailable() and repositories.allUnavailable() != repositories.allEnabled():
134136
for key in repositories.allUnavailable():
@@ -230,6 +232,7 @@ def exportPluginsToManager(self):
230232
"downloads": plugin["downloads"],
231233
"average_vote": plugin["average_vote"],
232234
"rating_votes": plugin["rating_votes"],
235+
"plugin_dependencies": plugin.get("plugin_dependencies", None),
233236
"pythonic": "true"
234237
})
235238
iface.pluginManagerInterface().reloadModel()
@@ -309,6 +312,7 @@ def installPlugin(self, key, quiet=False):
309312
QApplication.setOverrideCursor(Qt.WaitCursor)
310313
# update the list of plugins in plugin handling routines
311314
updateAvailablePlugins()
315+
self.processDependencies(plugin["id"])
312316
# try to load the plugin
313317
loadPlugin(plugin["id"])
314318
plugins.getAllInstalled()
@@ -417,6 +421,11 @@ def uninstallPlugin(self, key, quiet=False):
417421
exec("del sys.modules[%s]" % plugin["id"])
418422
except:
419423
pass
424+
try:
425+
exec("del plugins_metadata_parser[%s]" % plugin["id"])
426+
except:
427+
pass
428+
420429
plugins.getAllInstalled()
421430
plugins.rebuild()
422431
self.exportPluginsToManager()
@@ -599,6 +608,7 @@ def installFromZipFile(self, filePath):
599608

600609
if success:
601610
updateAvailablePlugins()
611+
self.processDependencies(pluginName)
602612
loadPlugin(pluginName)
603613
plugins.getAllInstalled()
604614
plugins.rebuild()
@@ -621,3 +631,32 @@ def installFromZipFile(self, filePath):
621631

622632
level = Qgis.Info if success else Qgis.Critical
623633
iface.pluginManagerInterface().pushMessage(msg, level)
634+
635+
def processDependencies(self, plugin_id):
636+
"""Processes plugin dependencies
637+
638+
:param plugin_id: plugin id
639+
:type plugin_id: str
640+
"""
641+
642+
to_install, to_upgrade, not_found = find_dependencies(plugin_id)
643+
if to_install or to_upgrade or not_found:
644+
dlg = QgsPluginDependenciesDialog(plugin_id, to_install, to_upgrade, not_found)
645+
if dlg.exec_() == QgsPluginDependenciesDialog.Accepted:
646+
actions = dlg.actions()
647+
for dependency_plugin_id, action in actions.items():
648+
try:
649+
self.installPlugin(dependency_plugin_id)
650+
if action == 'install':
651+
iface.pluginManagerInterface().pushMessage(self.tr("Plugin dependency <b>%s</b> successfully installed") %
652+
dependency_plugin_id, Qgis.Info)
653+
else:
654+
iface.pluginManagerInterface().pushMessage(self.tr("Plugin dependency <b>%s</b> successfully upgraded") %
655+
dependency_plugin_id, Qgis.Info)
656+
except Exception as ex:
657+
if action == 'install':
658+
iface.pluginManagerInterface().pushMessage(self.tr("Error installing plugin dependency <b>%s</b>: %s") %
659+
(dependency_plugin_id, ex), Qgis.Warning)
660+
else:
661+
iface.pluginManagerInterface().pushMessage(self.tr("Error upgrading plugin dependency <b>%s</b>: %s") %
662+
(dependency_plugin_id, ex), Qgis.Warning)

‎python/pyplugin_installer/installer_data.py

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@
8585
"downloads" unicode, # number of downloads
8686
"average_vote" unicode, # average vote
8787
"rating_votes" unicode, # number of votes
88+
"plugin_dependencies" unicode, # PIP-style comma separated list of plugin dependencies
8889
}}
8990
"""
9091

@@ -446,7 +447,8 @@ def xmlDownloaded(self):
446447
"version_installed": "",
447448
"zip_repository": reposName,
448449
"library": "",
449-
"readonly": False
450+
"readonly": False,
451+
"plugin_dependencies": pluginNodes.item(i).firstChildElement("plugin_dependencies").text().strip(),
450452
}
451453
qgisMinimumVersion = pluginNodes.item(i).firstChildElement("qgis_minimum_version").text().strip()
452454
if not qgisMinimumVersion:
@@ -674,7 +676,9 @@ def pluginMetadata(fct):
674676
"status": "orphan", # Will be overwritten, if any available version found.
675677
"error": error,
676678
"error_details": errorDetails,
677-
"readonly": readOnly}
679+
"readonly": readOnly,
680+
"plugin_dependencies": pluginMetadata("plugin_dependencies"),
681+
}
678682
return plugin
679683

680684
# ----------------------------------------- #
@@ -746,9 +750,9 @@ def rebuild(self):
746750
# other remote metadata is preferred:
747751
for attrib in ["name", "plugin_id", "description", "about", "category", "tags", "changelog", "author_name", "author_email", "homepage",
748752
"tracker", "code_repository", "experimental", "deprecated", "version_available", "zip_repository",
749-
"download_url", "filename", "downloads", "average_vote", "rating_votes", "trusted"]:
753+
"download_url", "filename", "downloads", "average_vote", "rating_votes", "trusted", "plugin_dependencies"]:
750754
if attrib not in translatableAttributes or attrib == "name": # include name!
751-
if plugin[attrib]:
755+
if plugin.get(attrib, False):
752756
self.mPlugins[key][attrib] = plugin[attrib]
753757
# set status
754758
#
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
# coding=utf-8
2+
"""Parse plugin metadata for plugin_dependencies and install/update
3+
required plugins
4+
5+
.. note:: This program is free software; you can redistribute it and/or modify
6+
it under the terms of the GNU General Public License as published by
7+
the Free Software Foundation; either version 2 of the License, or
8+
(at your option) any later version.
9+
10+
"""
11+
12+
__author__ = 'elpaso@itopen.it'
13+
__date__ = '2018-05-29'
14+
__copyright__ = 'Copyright 2018, GISCE-TI S.L.'
15+
16+
from configparser import NoOptionError, NoSectionError
17+
from .version_compare import compareVersions
18+
from . import installer as plugin_installer
19+
20+
21+
def __plugin_name_map(plugin_data_values):
22+
return {
23+
plugin['name']: plugin['id']
24+
for plugin in plugin_data_values
25+
}
26+
27+
28+
def __get_plugin_deps(plugin_id):
29+
30+
result = {}
31+
from qgis.utils import plugins_metadata_parser
32+
parser = plugins_metadata_parser[plugin_id]
33+
try:
34+
plugin_deps = parser.get('general', 'plugin_dependencies')
35+
except (NoOptionError, NoSectionError):
36+
return result
37+
38+
for dep in plugin_deps.split(','):
39+
if dep.find('==') > 0:
40+
name, version_required = dep.split('==')
41+
else:
42+
name = dep
43+
version_required = None
44+
result[name] = version_required
45+
return result
46+
47+
48+
def find_dependencies(plugin_id, plugin_data=None, plugin_deps=None, installed_plugins=None):
49+
"""Finds the plugin dependencies and checks if they can be installed or upgraded
50+
51+
:param plugin_id: plugin id
52+
:type plugin_id: str
53+
:param plugin_data: for testing only: dictionary of plugin data from the repo, defaults to None
54+
:param plugin_data: dict, optional
55+
:param plugin_deps: for testing only: dict of plugin id -> version_required, parsed from metadata value for "plugin_dependencies", defaults to None
56+
:param plugin_deps: dict, optional
57+
:param installed_plugins: for testing only: dict of plugin id -> version_installed
58+
:param installed_plugins: dict, optional
59+
:return: result dictionaries keyed by plugin name with: to_install, to_upgrade, not_found
60+
:rtype: tuple of dicts
61+
"""
62+
63+
to_install = {}
64+
to_upgrade = {}
65+
not_found = {}
66+
67+
if plugin_deps is None:
68+
plugin_deps = __get_plugin_deps(plugin_id)
69+
70+
if installed_plugins is None:
71+
from qgis.utils import plugins_metadata_parser
72+
installed_plugins = {plugins_metadata_parser[k].get('general', 'name'): plugins_metadata_parser[k].get('general', 'version') for k, v in plugins_metadata_parser.items()}
73+
74+
if plugin_data is None:
75+
plugin_data = plugin_installer.plugins.all()
76+
77+
plugins_map = __plugin_name_map(plugin_data.values())
78+
79+
# Review all dependencies
80+
for name, version_required in plugin_deps.items():
81+
try:
82+
p_id = plugins_map[name]
83+
except KeyError:
84+
not_found.update({name: {
85+
'id': None,
86+
'version_installed': None,
87+
'version_required': None,
88+
'version_available': None,
89+
'action': None,
90+
'error': 'missing_id'
91+
}})
92+
continue
93+
94+
affected_plugin = dict({
95+
"id": p_id,
96+
# "version_installed": installed_plugins.get(p_id, {}).get('installed_plugins', None),
97+
"version_installed": installed_plugins.get(name, None),
98+
"version_required": version_required,
99+
"version_available": plugin_data[p_id].get('version_available', None),
100+
"action": None,
101+
})
102+
103+
# Install is needed
104+
if name not in installed_plugins:
105+
affected_plugin['action'] = 'install'
106+
destination_list = to_install
107+
# Upgrade is needed
108+
elif version_required is not None and compareVersions(installed_plugins[name], version_required) == 2:
109+
affected_plugin['action'] = 'upgrade'
110+
destination_list = to_upgrade
111+
# TODO @elpaso: review installed but not activated
112+
# No action is needed
113+
else:
114+
continue
115+
116+
if affected_plugin['version_required'] == affected_plugin['version_available'] or affected_plugin['version_required'] is None:
117+
destination_list.update({name: affected_plugin})
118+
else:
119+
affected_plugin['error'] = 'unavailable {}'.format(affected_plugin['action'])
120+
not_found.update({name: affected_plugin})
121+
122+
return to_install, to_upgrade, not_found
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
# coding=utf-8
2+
"""Plugin update/install dialog
3+
4+
.. note:: This program is free software; you can redistribute it and/or modify
5+
it under the terms of the GNU General Public License as published by
6+
the Free Software Foundation; either version 2 of the License, or
7+
(at your option) any later version.
8+
9+
"""
10+
11+
__author__ = 'elpaso@itopen.it'
12+
__date__ = '2018-09-19'
13+
__copyright__ = 'Copyright 2018, GISCE-TI S.L.'
14+
15+
16+
import os
17+
18+
from qgis.PyQt import QtWidgets, QtCore
19+
from .ui_qgsplugindependenciesdialogbase import Ui_QgsPluginDependenciesDialogBase
20+
from qgis.utils import iface
21+
22+
23+
class QgsPluginDependenciesDialog(QtWidgets.QDialog, Ui_QgsPluginDependenciesDialogBase):
24+
"""A dialog that shows plugin dependencies and offers a way to install or upgrade the
25+
dependencies.
26+
"""
27+
28+
def __init__(self, plugin_name, to_install, to_upgrade, not_found, parent=None):
29+
"""Creates the dependencies dialog
30+
31+
:param plugin_name: the name of the parent plugin
32+
:type plugin_name: str
33+
:param to_install: list of plugin IDs that needs to be installed
34+
:type to_install: list
35+
:param to_upgrade: list of plugin IDs that needs to be upgraded
36+
:type to_upgrade: list
37+
:param not_found: list of plugin IDs that are not found (unvailable)
38+
:type not_found: list
39+
:param parent: parent object, defaults to None
40+
:param parent: QWidget, optional
41+
"""
42+
43+
super().__init__(parent)
44+
self.setupUi(self)
45+
self.setWindowTitle(self.tr("Plugin Dependencies Manager"))
46+
self.mPluginDependenciesLabel.setText(self.tr("Plugin dependencies for <b>%s</b>") % plugin_name)
47+
self.setStyleSheet("QTableView { padding: 20px;}")
48+
# Name, Version Installed, Version Required, Version Available, Action Checkbox
49+
self.pluginList.setColumnCount(5)
50+
self.pluginList.setHorizontalHeaderLabels([self.tr('Name'), self.tr('Installed'), self.tr('Required'), self.tr('Available'), self.tr('Action')])
51+
self.pluginList.setRowCount(len(not_found) + len(to_install) + len(to_upgrade))
52+
self.__actions = {}
53+
54+
def _display(txt):
55+
if txt is None:
56+
return ""
57+
return txt
58+
59+
def _make_row(data, i, name):
60+
widget = QtWidgets.QLabel("<b>%s</b>" % name)
61+
widget.p_id = data['id']
62+
widget.action = data['action']
63+
self.pluginList.setCellWidget(i, 0, widget)
64+
widget = QtWidgets.QTableWidgetItem(_display(data['version_installed']))
65+
widget.setTextAlignment(QtCore.Qt.AlignHCenter | QtCore.Qt.AlignVCenter)
66+
self.pluginList.setItem(i, 1, widget)
67+
widget = QtWidgets.QTableWidgetItem(_display(data['version_required']))
68+
widget.setTextAlignment(QtCore.Qt.AlignHCenter | QtCore.Qt.AlignVCenter)
69+
self.pluginList.setItem(i, 2, widget)
70+
widget = QtWidgets.QTableWidgetItem(_display(data['version_available']))
71+
widget.setTextAlignment(QtCore.Qt.AlignHCenter | QtCore.Qt.AlignVCenter)
72+
self.pluginList.setItem(i, 3, widget)
73+
74+
i = 0
75+
for name, data in to_install.items():
76+
_make_row(data, i, name)
77+
widget = QtWidgets.QCheckBox(self.tr("Install"))
78+
widget.setChecked(True)
79+
self.pluginList.setCellWidget(i, 4, widget)
80+
i += 1
81+
82+
for name, data in to_upgrade.items():
83+
_make_row(data, i, name)
84+
widget = QtWidgets.QCheckBox(self.tr("Upgrade"))
85+
widget.setChecked(True)
86+
self.pluginList.setCellWidget(i, 4, widget)
87+
i += 1
88+
89+
for name, data in not_found.items():
90+
_make_row(data, i, name)
91+
widget = QtWidgets.QLabel(self.tr("Fix manually"))
92+
self.pluginList.setCellWidget(i, 4, widget)
93+
i += 1
94+
95+
def actions(self):
96+
"""Returns the list of actions
97+
98+
:return: dict of actions
99+
:rtype: dict
100+
"""
101+
102+
return self.__actions
103+
104+
def accept(self):
105+
self.__actions = {}
106+
for i in range(self.pluginList.rowCount()):
107+
try:
108+
if self.pluginList.cellWidget(i, 4).isChecked():
109+
self.__actions[self.pluginList.cellWidget(i, 0).p_id] = self.pluginList.cellWidget(i, 0).action
110+
except:
111+
pass
112+
super().accept()
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<ui version="4.0">
3+
<class>QgsPluginDependenciesDialogBase</class>
4+
<widget class="QDialog" name="QgsPluginDependenciesDialogBase">
5+
<property name="geometry">
6+
<rect>
7+
<x>0</x>
8+
<y>0</y>
9+
<width>1211</width>
10+
<height>437</height>
11+
</rect>
12+
</property>
13+
<property name="windowTitle">
14+
<string>Dialog</string>
15+
</property>
16+
<layout class="QVBoxLayout" name="verticalLayout">
17+
<item>
18+
<widget class="QLabel" name="mPluginDependenciesLabel">
19+
<property name="text">
20+
<string>Plugin dependencies</string>
21+
</property>
22+
</widget>
23+
</item>
24+
<item>
25+
<widget class="QTableWidget" name="pluginList">
26+
<property name="editTriggers">
27+
<set>QAbstractItemView::NoEditTriggers</set>
28+
</property>
29+
<property name="selectionMode">
30+
<enum>QAbstractItemView::NoSelection</enum>
31+
</property>
32+
<attribute name="horizontalHeaderShowSortIndicator" stdset="0">
33+
<bool>false</bool>
34+
</attribute>
35+
<attribute name="verticalHeaderVisible">
36+
<bool>false</bool>
37+
</attribute>
38+
<attribute name="verticalHeaderHighlightSections">
39+
<bool>false</bool>
40+
</attribute>
41+
</widget>
42+
</item>
43+
<item>
44+
<widget class="QDialogButtonBox" name="buttonBox">
45+
<property name="orientation">
46+
<enum>Qt::Horizontal</enum>
47+
</property>
48+
<property name="standardButtons">
49+
<set>QDialogButtonBox::Cancel|QDialogButtonBox::Ok</set>
50+
</property>
51+
</widget>
52+
</item>
53+
</layout>
54+
</widget>
55+
<resources/>
56+
<connections>
57+
<connection>
58+
<sender>buttonBox</sender>
59+
<signal>accepted()</signal>
60+
<receiver>QgsPluginDependenciesDialogBase</receiver>
61+
<slot>accept()</slot>
62+
<hints>
63+
<hint type="sourcelabel">
64+
<x>248</x>
65+
<y>254</y>
66+
</hint>
67+
<hint type="destinationlabel">
68+
<x>157</x>
69+
<y>274</y>
70+
</hint>
71+
</hints>
72+
</connection>
73+
<connection>
74+
<sender>buttonBox</sender>
75+
<signal>rejected()</signal>
76+
<receiver>QgsPluginDependenciesDialogBase</receiver>
77+
<slot>reject()</slot>
78+
<hints>
79+
<hint type="sourcelabel">
80+
<x>316</x>
81+
<y>260</y>
82+
</hint>
83+
<hint type="destinationlabel">
84+
<x>286</x>
85+
<y>274</y>
86+
</hint>
87+
</hints>
88+
</connection>
89+
</connections>
90+
</ui>

‎src/app/pluginmanager/qgspluginmanager.cpp

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -995,6 +995,13 @@ void QgsPluginManager::showPluginDetails( QStandardItem *item )
995995
html += QStringLiteral( "<tr><td class='key'>%1 </td><td>%2</td></tr>" ).arg( tr( "Changelog" ), changelog );
996996
}
997997

998+
if ( ! metadata->value( QStringLiteral( "plugin_dependencies" ) ).isEmpty() )
999+
{
1000+
QString pluginDependencies = metadata->value( QStringLiteral( "plugin_dependencies" ) );
1001+
pluginDependencies = pluginDependencies.trimmed();
1002+
html += QStringLiteral( "<tr><td class='key'>%1 </td><td>%2</td></tr>" ).arg( tr( "Plugin dependencies" ), pluginDependencies );
1003+
}
1004+
9981005
html += QLatin1String( "</table>" );
9991006

10001007
html += QLatin1String( "</body>" );

0 commit comments

Comments
 (0)
Please sign in to comment.