mirror of
https://github.com/qgis/QGIS.git
synced 2025-10-07 00:15:48 -04:00
1243 lines
53 KiB
Python
1243 lines
53 KiB
Python
"""
|
|
/***************************************************************************
|
|
Plugin Installer module
|
|
-------------------
|
|
Date : May 2013
|
|
Copyright : (C) 2013 by Borys Jurgiel
|
|
Email : info at borysjurgiel dot pl
|
|
|
|
This module is based on former plugin_installer plugin:
|
|
Copyright (C) 2007-2008 Matthew Perry
|
|
Copyright (C) 2008-2013 Borys Jurgiel
|
|
|
|
***************************************************************************/
|
|
|
|
/***************************************************************************
|
|
* *
|
|
* 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. *
|
|
* *
|
|
***************************************************************************/
|
|
"""
|
|
|
|
from typing import Dict, Optional, Any
|
|
|
|
from qgis.PyQt.QtCore import (
|
|
pyqtSignal,
|
|
QObject,
|
|
QCoreApplication,
|
|
QFile,
|
|
QDir,
|
|
QDirIterator,
|
|
QDate,
|
|
QUrl,
|
|
QFileInfo,
|
|
QLocale,
|
|
QByteArray,
|
|
QT_VERSION_STR,
|
|
)
|
|
from qgis.PyQt.QtXml import QDomDocument
|
|
from qgis.PyQt.QtNetwork import QNetworkRequest, QNetworkReply
|
|
from qgis.core import Qgis, QgsSettings, QgsSettingsTree, QgsNetworkRequestParameters
|
|
import sys
|
|
import os
|
|
import codecs
|
|
import re
|
|
import configparser
|
|
import qgis.utils
|
|
from qgis.core import QgsNetworkAccessManager, QgsApplication
|
|
from qgis.gui import QgsGui
|
|
from qgis.utils import iface, plugin_paths, HOME_PLUGIN_PATH
|
|
from .version_compare import (
|
|
pyQgisVersion,
|
|
compareVersions,
|
|
normalizeVersion,
|
|
isCompatible,
|
|
)
|
|
|
|
|
|
"""
|
|
Data structure:
|
|
mRepositories = dict of dicts: {repoName : {"url" unicode,
|
|
"enabled" bool,
|
|
"valid" bool,
|
|
"Relay" Relay, # Relay object for transmitting signals from QPHttp with adding the repoName information
|
|
"Request" QNetworkRequest,
|
|
"xmlData" QNetworkReply,
|
|
"state" int, (0 - disabled, 1-loading, 2-loaded ok, 3-error (to be retried), 4-rejected)
|
|
"error" unicode}}
|
|
|
|
|
|
mPlugins = dict of dicts {id : {
|
|
"id" unicode # module name
|
|
"name" unicode, # human readable plugin name
|
|
"description" unicode, # short description of the plugin purpose only
|
|
"about" unicode, # longer description: how does it work, where does it install, how to run it?
|
|
"category" unicode, # will be removed?
|
|
"tags" unicode, # comma separated, spaces allowed
|
|
"changelog" unicode, # may be multiline
|
|
"author_name" unicode, # author name
|
|
"author_email" unicode, # author email
|
|
"homepage" unicode, # url to the plugin homepage
|
|
"tracker" unicode, # url to a tracker site
|
|
"code_repository" unicode, # url to the source code repository
|
|
"version_installed" unicode, # installed instance version
|
|
"library" unicode, # absolute path to the installed library / Python module
|
|
"icon" unicode, # path to the first:(INSTALLED | AVAILABLE) icon
|
|
"pythonic" const bool=True # True if Python plugin
|
|
"readonly" boolean, # True if core plugin
|
|
"installed" boolean, # True if installed
|
|
"available" boolean, # True if available in repositories
|
|
"status" unicode, # ( not installed | new ) | ( installed | upgradeable | orphan | newer )
|
|
"status_exp" unicode, # ( not installed | new ) | ( installed | upgradeable | orphan | newer )
|
|
"error" unicode, # NULL | broken | incompatible | dependent
|
|
"error_details" unicode, # error description
|
|
"experimental" boolean, # true if experimental, false if stable
|
|
"deprecated" boolean, # true if deprecated, false if actual
|
|
"trusted" boolean, # true if trusted, false if not trusted
|
|
"version_available" unicode, # available version
|
|
"version_available_stable" unicode, # available stable version
|
|
"version_available_experimental" unicode, # available experimental version
|
|
"zip_repository" unicode, # the remote repository id
|
|
"download_url" unicode, # url for downloading the plugin
|
|
"download_url_stable" unicode, # url for downloading the plugin's stable version
|
|
"download_url_experimental" unicode, # url for downloading the plugin's experimental version
|
|
"filename" unicode, # the zip file name to be unzipped after downloaded
|
|
"downloads" unicode, # number of downloads
|
|
"average_vote" unicode, # average vote
|
|
"rating_votes" unicode, # number of votes
|
|
"create_date" unicode, # ISO datetime when the plugin has been created
|
|
"update_date" unicode, # ISO datetime when the plugin has been last updated
|
|
"plugin_dependencies" unicode, # PIP-style comma separated list of plugin dependencies
|
|
}}
|
|
"""
|
|
|
|
|
|
translatableAttributes = ["name", "description", "about", "tags"]
|
|
|
|
reposGroup = "app/plugin_repositories"
|
|
|
|
officialRepo = (
|
|
QCoreApplication.translate("QgsPluginInstaller", "QGIS Official Plugin Repository"),
|
|
"https://plugins.qgis.org/plugins/plugins.xml",
|
|
)
|
|
|
|
|
|
# --- common functions ------------------------------------------------------------------- #
|
|
def removeDir(path):
|
|
result = ""
|
|
if not QFile(path).exists():
|
|
result = (
|
|
QCoreApplication.translate(
|
|
"QgsPluginInstaller",
|
|
"Nothing to remove! Plugin directory doesn't exist:",
|
|
)
|
|
+ "\n"
|
|
+ path
|
|
)
|
|
elif QFile(path).remove(): # if it is only link, just remove it without resolving.
|
|
pass
|
|
else:
|
|
fltr = QDir.Filter.Dirs | QDir.Filter.Files | QDir.Filter.Hidden
|
|
iterator = QDirIterator(path, fltr, QDirIterator.IteratorFlag.Subdirectories)
|
|
while iterator.hasNext():
|
|
item = iterator.next()
|
|
if QFile(item).remove():
|
|
pass
|
|
fltr = QDir.Filter.Dirs | QDir.Filter.Hidden
|
|
iterator = QDirIterator(path, fltr, QDirIterator.IteratorFlag.Subdirectories)
|
|
while iterator.hasNext():
|
|
item = iterator.next()
|
|
if QDir().rmpath(item):
|
|
pass
|
|
if QFile(path).exists():
|
|
result = (
|
|
QCoreApplication.translate(
|
|
"QgsPluginInstaller", "Failed to remove the directory:"
|
|
)
|
|
+ "\n"
|
|
+ path
|
|
+ "\n"
|
|
+ QCoreApplication.translate(
|
|
"QgsPluginInstaller", "Check permissions or remove it manually"
|
|
)
|
|
)
|
|
# restore plugin directory if removed by QDir().rmpath()
|
|
pluginDir = HOME_PLUGIN_PATH
|
|
if not QDir(pluginDir).exists():
|
|
QDir().mkpath(pluginDir)
|
|
return result
|
|
|
|
|
|
class Relay(QObject):
|
|
"""
|
|
Relay object for transmitting signals from QPHttp with adding the repoName information
|
|
"""
|
|
|
|
anythingChanged = pyqtSignal(str, int, int)
|
|
|
|
def __init__(self, key):
|
|
QObject.__init__(self)
|
|
self.key = key
|
|
|
|
def stateChanged(self, state):
|
|
self.anythingChanged.emit(self.key, state, 0)
|
|
|
|
def dataReadProgress(self, done, total):
|
|
state = Repositories.STATE_REJECTED
|
|
if total > 0:
|
|
progress = int(float(done) / float(total) * 100)
|
|
else:
|
|
progress = 0
|
|
self.anythingChanged.emit(self.key, state, progress)
|
|
|
|
|
|
class Repositories(QObject):
|
|
"""
|
|
A dict-like class for handling repositories data
|
|
"""
|
|
|
|
STATE_DISABLED = 0
|
|
STATE_LOADING = 1
|
|
STATE_LOADED = 2
|
|
STATE_UNAVAILABLE = 3
|
|
STATE_REJECTED = 4
|
|
|
|
CHECK_ON_START_INTERVAL = 3
|
|
|
|
anythingChanged = pyqtSignal(str, int, int)
|
|
repositoryFetched = pyqtSignal(str)
|
|
checkingDone = pyqtSignal()
|
|
|
|
def __init__(self):
|
|
QObject.__init__(self)
|
|
self.mRepositories: dict[str, dict[str, Any]] = {}
|
|
self.httpId = {} # {httpId : repoName}
|
|
self.mInspectionFilter: Optional[str] = None
|
|
|
|
def all(self) -> dict[str, dict[str, Any]]:
|
|
"""return dict of all repositories"""
|
|
return self.mRepositories
|
|
|
|
def allEnabled(self) -> dict[str, dict[str, Any]]:
|
|
"""return dict of all enabled and valid repositories"""
|
|
if self.mInspectionFilter:
|
|
return {self.mInspectionFilter: self.mRepositories[self.mInspectionFilter]}
|
|
|
|
return {
|
|
k: v for k, v in self.mRepositories.items() if v["enabled"] and v["valid"]
|
|
}
|
|
|
|
def allUnavailable(self) -> dict[str, dict[str, Any]]:
|
|
"""return dict of all unavailable repositories"""
|
|
repos = {}
|
|
|
|
if self.mInspectionFilter:
|
|
# return the inspected repo if unavailable, otherwise empty dict
|
|
if (
|
|
self.mRepositories[self.mInspectionFilter]["state"]
|
|
== Repositories.STATE_UNAVAILABLE
|
|
):
|
|
repos[self.mInspectionFilter] = self.mRepositories[
|
|
self.mInspectionFilter
|
|
]
|
|
return repos
|
|
|
|
return {
|
|
k: v
|
|
for k, v in self.mRepositories.items()
|
|
if v["enabled"]
|
|
and v["valid"]
|
|
and v["state"] == Repositories.STATE_UNAVAILABLE
|
|
}
|
|
|
|
def urlParams(self) -> str:
|
|
"""return GET parameters to be added to every request"""
|
|
# Strip down the point release segment from the version string
|
|
return "?qgis={}".format(re.sub(r"\.\d*$", "", pyQgisVersion()))
|
|
|
|
def setRepositoryData(self, reposName: str, key: str, value):
|
|
"""write data to the mRepositories dict"""
|
|
self.mRepositories[reposName][key] = value
|
|
|
|
def remove(self, reposName: str):
|
|
"""remove given item from the mRepositories dict"""
|
|
del self.mRepositories[reposName]
|
|
|
|
def rename(self, oldName: str, newName: str):
|
|
"""rename repository key"""
|
|
if oldName == newName:
|
|
return
|
|
self.mRepositories[newName] = self.mRepositories[oldName]
|
|
del self.mRepositories[oldName]
|
|
|
|
def checkingOnStart(self) -> bool:
|
|
"""return true if checking for news and updates is enabled"""
|
|
return (
|
|
QgsSettingsTree.node("plugin-manager")
|
|
.childSetting("automatically-check-for-updates")
|
|
.value()
|
|
)
|
|
|
|
def setCheckingOnStart(self, state: bool):
|
|
"""set state of checking for news and updates"""
|
|
QgsSettingsTree.node("plugin-manager").childSetting(
|
|
"automatically-check-for-updates"
|
|
).setValue(state)
|
|
|
|
def saveCheckingOnStartLastDate(self):
|
|
"""set today's date as the day of last checking"""
|
|
QgsSettingsTree.node("plugin-manager").childSetting(
|
|
"check-on-start-last-date"
|
|
).setValue(QDate.currentDate())
|
|
|
|
def timeForChecking(self) -> bool:
|
|
"""determine whether it's the time for checking for news and updates now"""
|
|
settings = QgsSettings()
|
|
try:
|
|
# QgsSettings may contain ivalid value...
|
|
interval = (
|
|
QgsSettingsTree.node("plugin-manager")
|
|
.childSetting("check-on-start-last-date")
|
|
.valueAs(type=QDate)
|
|
.daysTo(QDate.currentDate())
|
|
)
|
|
except:
|
|
interval = 0
|
|
if interval >= Repositories.CHECK_ON_START_INTERVAL:
|
|
return True
|
|
else:
|
|
return False
|
|
|
|
def load(self):
|
|
"""populate the mRepositories dict"""
|
|
self.mRepositories = {}
|
|
settings = QgsSettings()
|
|
settings.beginGroup(reposGroup)
|
|
# first, update repositories in QgsSettings if needed
|
|
officialRepoPresent = False
|
|
for key in settings.childGroups():
|
|
url = settings.value(key + "/url", "", type=str)
|
|
if url == officialRepo[1]:
|
|
officialRepoPresent = True
|
|
if not officialRepoPresent:
|
|
settings.setValue(officialRepo[0] + "/url", officialRepo[1])
|
|
|
|
for key in settings.childGroups():
|
|
self.mRepositories[key] = {}
|
|
self.mRepositories[key]["url"] = settings.value(key + "/url", "", type=str)
|
|
self.mRepositories[key]["authcfg"] = settings.value(
|
|
key + "/authcfg", "", type=str
|
|
)
|
|
self.mRepositories[key]["enabled"] = settings.value(
|
|
key + "/enabled", True, type=bool
|
|
)
|
|
self.mRepositories[key]["valid"] = settings.value(
|
|
key + "/valid", True, type=bool
|
|
)
|
|
self.mRepositories[key]["Relay"] = Relay(key)
|
|
self.mRepositories[key]["xmlData"] = None
|
|
self.mRepositories[key]["state"] = Repositories.STATE_DISABLED
|
|
self.mRepositories[key]["error"] = ""
|
|
settings.endGroup()
|
|
|
|
def requestFetching(
|
|
self,
|
|
key: str,
|
|
url: Optional[QUrl] = None,
|
|
redirectionCounter=0,
|
|
force_reload: bool = False,
|
|
):
|
|
"""start fetching the repository given by key"""
|
|
self.mRepositories[key]["state"] = Repositories.STATE_LOADING
|
|
if not url:
|
|
url = QUrl(self.mRepositories[key]["url"] + self.urlParams())
|
|
# v=str(Qgis.QGIS_VERSION_INT)
|
|
# url.addQueryItem('qgis', '.'.join([str(int(s)) for s in [v[0], v[1:3]]]) ) # don't include the bugfix version!
|
|
|
|
self.mRepositories[key]["QRequest"] = QNetworkRequest(url)
|
|
self.mRepositories[key]["QRequest"].setAttribute(
|
|
QNetworkRequest.Attribute(
|
|
QgsNetworkRequestParameters.RequestAttributes.AttributeInitiatorClass
|
|
),
|
|
"Relay",
|
|
)
|
|
self.mRepositories[key]["QRequest"].setAttribute(
|
|
QNetworkRequest.Attribute.RedirectPolicyAttribute,
|
|
QNetworkRequest.RedirectPolicy.NoLessSafeRedirectPolicy,
|
|
)
|
|
if force_reload:
|
|
self.mRepositories[key]["QRequest"].setAttribute(
|
|
QNetworkRequest.Attribute.CacheLoadControlAttribute,
|
|
QNetworkRequest.CacheLoadControl.AlwaysNetwork,
|
|
)
|
|
authcfg = self.mRepositories[key]["authcfg"]
|
|
if authcfg and isinstance(authcfg, str):
|
|
if not QgsApplication.authManager().updateNetworkRequest(
|
|
self.mRepositories[key]["QRequest"], authcfg.strip()
|
|
):
|
|
msg = QCoreApplication.translate(
|
|
"QgsPluginInstaller",
|
|
"Update of network request with authentication "
|
|
"credentials FAILED for configuration '{0}'",
|
|
).format(authcfg)
|
|
iface.pluginManagerInterface().pushMessage(
|
|
msg, Qgis.MessageLevel.Warning
|
|
)
|
|
self.mRepositories[key]["QRequest"] = None
|
|
return
|
|
self.mRepositories[key]["QRequest"].setAttribute(
|
|
QNetworkRequest.Attribute.User, key
|
|
)
|
|
self.mRepositories[key]["xmlData"] = QgsNetworkAccessManager.instance().get(
|
|
self.mRepositories[key]["QRequest"]
|
|
)
|
|
self.mRepositories[key]["xmlData"].setProperty("reposName", key)
|
|
self.mRepositories[key]["xmlData"].setProperty(
|
|
"redirectionCounter", redirectionCounter
|
|
)
|
|
self.mRepositories[key]["xmlData"].downloadProgress.connect(
|
|
self.mRepositories[key]["Relay"].dataReadProgress
|
|
)
|
|
self.mRepositories[key]["xmlDataFinished"] = self.mRepositories[key][
|
|
"xmlData"
|
|
].finished.connect(self.xmlDownloaded)
|
|
|
|
def fetchingInProgress(self) -> bool:
|
|
"""return True if fetching repositories is still in progress"""
|
|
return any(
|
|
v["state"] == Repositories.STATE_LOADING
|
|
for v in self.mRepositories.values()
|
|
)
|
|
|
|
def killConnection(self, key: str):
|
|
"""kill the fetching on demand"""
|
|
if (
|
|
self.mRepositories[key]["state"] == Repositories.STATE_LOADING
|
|
and self.mRepositories[key]["xmlData"]
|
|
and self.mRepositories[key]["xmlData"].isRunning()
|
|
):
|
|
self.mRepositories[key]["xmlData"].finished.disconnect(
|
|
self.mRepositories[key]["xmlDataFinished"]
|
|
)
|
|
self.mRepositories[key]["xmlData"].abort()
|
|
|
|
def xmlDownloaded(self):
|
|
"""populate the plugins object with the fetched data"""
|
|
reply = self.sender()
|
|
reposName = reply.property("reposName")
|
|
if reply.error() != QNetworkReply.NetworkError.NoError: # fetching failed
|
|
self.mRepositories[reposName]["state"] = Repositories.STATE_UNAVAILABLE
|
|
self.mRepositories[reposName]["error"] = reply.errorString()
|
|
if reply.error() == QNetworkReply.NetworkError.OperationCanceledError:
|
|
self.mRepositories[reposName][
|
|
"error"
|
|
] += "\n\n" + QCoreApplication.translate(
|
|
"QgsPluginInstaller",
|
|
"If you haven't canceled the download manually, it was most likely caused by a timeout. In this case consider increasing the connection timeout value in QGIS options window.",
|
|
)
|
|
elif reply.attribute(QNetworkRequest.Attribute.HttpStatusCodeAttribute) == 301:
|
|
redirectionUrl = reply.attribute(
|
|
QNetworkRequest.Attribute.RedirectionTargetAttribute
|
|
)
|
|
if redirectionUrl.isRelative():
|
|
redirectionUrl = reply.url().resolved(redirectionUrl)
|
|
redirectionCounter = reply.property("redirectionCounter") + 1
|
|
if redirectionCounter > 4:
|
|
self.mRepositories[reposName]["state"] = Repositories.STATE_UNAVAILABLE
|
|
self.mRepositories[reposName]["error"] = QCoreApplication.translate(
|
|
"QgsPluginInstaller", "Too many redirections"
|
|
)
|
|
else:
|
|
# Fire a new request and exit immediately in order to quietly destroy the old one
|
|
self.requestFetching(reposName, redirectionUrl, redirectionCounter)
|
|
reply.deleteLater()
|
|
return
|
|
else:
|
|
reposXML = QDomDocument()
|
|
content = reply.readAll()
|
|
# Fix lonely ampersands in metadata
|
|
content = content.replace(b"& ", b"& ")
|
|
reposXML.setContent(content)
|
|
plugins_tag = reposXML.elementsByTagName("plugins")
|
|
if plugins_tag.size():
|
|
pluginNodes = reposXML.elementsByTagName("pyqgis_plugin")
|
|
for i in range(pluginNodes.size()):
|
|
fileName = (
|
|
pluginNodes.item(i)
|
|
.firstChildElement("file_name")
|
|
.text()
|
|
.strip()
|
|
)
|
|
if not fileName:
|
|
fileName = QFileInfo(
|
|
pluginNodes.item(i)
|
|
.firstChildElement("download_url")
|
|
.text()
|
|
.strip()
|
|
.split("?")[0]
|
|
).fileName()
|
|
name = fileName.partition(".")[0]
|
|
experimental = False
|
|
if pluginNodes.item(i).firstChildElement(
|
|
"experimental"
|
|
).text().strip().upper() in ["TRUE", "YES"]:
|
|
experimental = True
|
|
deprecated = False
|
|
if pluginNodes.item(i).firstChildElement(
|
|
"deprecated"
|
|
).text().strip().upper() in ["TRUE", "YES"]:
|
|
deprecated = True
|
|
trusted = False
|
|
if pluginNodes.item(i).firstChildElement(
|
|
"trusted"
|
|
).text().strip().upper() in ["TRUE", "YES"]:
|
|
trusted = True
|
|
icon = pluginNodes.item(i).firstChildElement("icon").text().strip()
|
|
if icon and not icon.startswith("http"):
|
|
url = QUrl(self.mRepositories[reposName]["url"])
|
|
if url.scheme() in ("http", "https"):
|
|
icon = f"{url.scheme()}://{url.host()}/{icon}"
|
|
|
|
if pluginNodes.item(i).toElement().hasAttribute("plugin_id"):
|
|
plugin_id = (
|
|
pluginNodes.item(i).toElement().attribute("plugin_id")
|
|
)
|
|
else:
|
|
plugin_id = None
|
|
|
|
version = pluginNodes.item(i).toElement().attribute("version")
|
|
download_url = (
|
|
pluginNodes.item(i)
|
|
.firstChildElement("download_url")
|
|
.text()
|
|
.strip()
|
|
)
|
|
|
|
plugin = {
|
|
"id": name,
|
|
"plugin_id": plugin_id,
|
|
"name": pluginNodes.item(i).toElement().attribute("name"),
|
|
"version_available": version,
|
|
"version_available_stable": (
|
|
normalizeVersion(version) if not experimental else ""
|
|
),
|
|
"version_available_experimental": (
|
|
normalizeVersion(version) if experimental else ""
|
|
),
|
|
"description": pluginNodes.item(i)
|
|
.firstChildElement("description")
|
|
.text()
|
|
.strip(),
|
|
"about": pluginNodes.item(i)
|
|
.firstChildElement("about")
|
|
.text()
|
|
.strip(),
|
|
"author_name": pluginNodes.item(i)
|
|
.firstChildElement("author_name")
|
|
.text()
|
|
.strip(),
|
|
"homepage": pluginNodes.item(i)
|
|
.firstChildElement("homepage")
|
|
.text()
|
|
.strip(),
|
|
"download_url": download_url,
|
|
"download_url_stable": download_url if not experimental else "",
|
|
"download_url_experimental": (
|
|
download_url if experimental else ""
|
|
),
|
|
"category": pluginNodes.item(i)
|
|
.firstChildElement("category")
|
|
.text()
|
|
.strip(),
|
|
"tags": pluginNodes.item(i)
|
|
.firstChildElement("tags")
|
|
.text()
|
|
.strip(),
|
|
"changelog": pluginNodes.item(i)
|
|
.firstChildElement("changelog")
|
|
.text()
|
|
.strip(),
|
|
"author_email": pluginNodes.item(i)
|
|
.firstChildElement("author_email")
|
|
.text()
|
|
.strip(),
|
|
"tracker": pluginNodes.item(i)
|
|
.firstChildElement("tracker")
|
|
.text()
|
|
.strip(),
|
|
"code_repository": pluginNodes.item(i)
|
|
.firstChildElement("repository")
|
|
.text()
|
|
.strip(),
|
|
"downloads": pluginNodes.item(i)
|
|
.firstChildElement("downloads")
|
|
.text()
|
|
.strip(),
|
|
"average_vote": pluginNodes.item(i)
|
|
.firstChildElement("average_vote")
|
|
.text()
|
|
.strip(),
|
|
"rating_votes": pluginNodes.item(i)
|
|
.firstChildElement("rating_votes")
|
|
.text()
|
|
.strip(),
|
|
"create_date": pluginNodes.item(i)
|
|
.firstChildElement("create_date")
|
|
.text()
|
|
.strip(),
|
|
"update_date": pluginNodes.item(i)
|
|
.firstChildElement("update_date")
|
|
.text()
|
|
.strip(),
|
|
"create_date_stable": (
|
|
pluginNodes.item(i)
|
|
.firstChildElement("create_date")
|
|
.text()
|
|
.strip()
|
|
if not experimental
|
|
else ""
|
|
),
|
|
"update_date_stable": (
|
|
pluginNodes.item(i)
|
|
.firstChildElement("update_date")
|
|
.text()
|
|
.strip()
|
|
if not experimental
|
|
else ""
|
|
),
|
|
"create_date_experimental": (
|
|
pluginNodes.item(i)
|
|
.firstChildElement("create_date")
|
|
.text()
|
|
.strip()
|
|
if experimental
|
|
else ""
|
|
),
|
|
"update_date_experimental": (
|
|
pluginNodes.item(i)
|
|
.firstChildElement("update_date")
|
|
.text()
|
|
.strip()
|
|
if experimental
|
|
else ""
|
|
),
|
|
"icon": icon,
|
|
"experimental": experimental,
|
|
"deprecated": deprecated,
|
|
"trusted": trusted,
|
|
"filename": fileName,
|
|
"installed": False,
|
|
"available": True,
|
|
"status": "not installed",
|
|
"status_exp": "not installed",
|
|
"error": "",
|
|
"error_details": "",
|
|
"version_installed": "",
|
|
"zip_repository": reposName,
|
|
"library": "",
|
|
"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:
|
|
qgisMinimumVersion = "2"
|
|
qgisMaximumVersion = (
|
|
pluginNodes.item(i)
|
|
.firstChildElement("qgis_maximum_version")
|
|
.text()
|
|
.strip()
|
|
)
|
|
if not qgisMaximumVersion:
|
|
qgisMaximumVersion = qgisMinimumVersion[0] + ".99"
|
|
# if compatible, add the plugin to the list
|
|
if not pluginNodes.item(i).firstChildElement(
|
|
"disabled"
|
|
).text().strip().upper() in ["TRUE", "YES"]:
|
|
if isCompatible(
|
|
pyQgisVersion(), qgisMinimumVersion, qgisMaximumVersion
|
|
):
|
|
# add the plugin to the cache
|
|
plugins.addFromRepository(plugin)
|
|
self.mRepositories[reposName]["state"] = Repositories.STATE_LOADED
|
|
else:
|
|
# no plugin metadata found
|
|
self.mRepositories[reposName]["state"] = Repositories.STATE_UNAVAILABLE
|
|
if (
|
|
reply.attribute(QNetworkRequest.Attribute.HttpStatusCodeAttribute)
|
|
== 200
|
|
):
|
|
self.mRepositories[reposName]["error"] = QCoreApplication.translate(
|
|
"QgsPluginInstaller",
|
|
"Server response is 200 OK, but doesn't contain plugin metadata. This is most likely caused by a proxy or a wrong repository URL. You can configure proxy settings in QGIS options.",
|
|
)
|
|
else:
|
|
self.mRepositories[reposName]["error"] = QCoreApplication.translate(
|
|
"QgsPluginInstaller", "Status code:"
|
|
) + " {} {}".format(
|
|
reply.attribute(
|
|
QNetworkRequest.Attribute.HttpStatusCodeAttribute
|
|
),
|
|
reply.attribute(
|
|
QNetworkRequest.Attribute.HttpReasonPhraseAttribute
|
|
),
|
|
)
|
|
|
|
self.repositoryFetched.emit(reposName)
|
|
|
|
# is the checking done?
|
|
if not self.fetchingInProgress():
|
|
self.checkingDone.emit()
|
|
|
|
reply.deleteLater()
|
|
|
|
def inspectionFilter(self) -> Optional[str]:
|
|
"""return inspection filter (only one repository to be fetched)"""
|
|
return self.mInspectionFilter
|
|
|
|
def setInspectionFilter(self, key: Optional[str] = None):
|
|
"""temporarily disable all repositories but this for inspection"""
|
|
self.mInspectionFilter = key
|
|
|
|
|
|
# --- class Plugins ---------------------------------------------------------------------- #
|
|
class Plugins(QObject):
|
|
"""A dict-like class for handling plugins data"""
|
|
|
|
# ----------------------------------------- #
|
|
|
|
def __init__(self):
|
|
QObject.__init__(self)
|
|
self.mPlugins = {} # the dict of plugins (dicts)
|
|
self.repoCache = {} # the dict of lists of plugins (dicts)
|
|
self.localCache = {} # the dict of plugins (dicts)
|
|
self.obsoletePlugins = (
|
|
[]
|
|
) # the list of outdated 'user' plugins masking newer 'system' ones
|
|
|
|
# ----------------------------------------- #
|
|
def all(self):
|
|
"""return all plugins"""
|
|
return self.mPlugins
|
|
|
|
# ----------------------------------------- #
|
|
def allUpgradeable(self):
|
|
"""return all upgradeable plugins"""
|
|
result = {}
|
|
for i in self.mPlugins:
|
|
if self.mPlugins[i]["status"] == "upgradeable":
|
|
result[i] = self.mPlugins[i]
|
|
return result
|
|
|
|
# ----------------------------------------- #
|
|
def keyByUrl(self, name):
|
|
"""return plugin key by given url"""
|
|
plugins = [i for i in self.mPlugins if self.mPlugins[i]["download_url"] == name]
|
|
if plugins:
|
|
return plugins[0]
|
|
return None
|
|
|
|
# ----------------------------------------- #
|
|
def clearRepoCache(self):
|
|
"""clears the repo cache before re-fetching repositories"""
|
|
self.repoCache = {}
|
|
|
|
# ----------------------------------------- #
|
|
def addFromRepository(self, plugin):
|
|
"""add given plugin to the repoCache"""
|
|
repo = plugin["zip_repository"]
|
|
try:
|
|
self.repoCache[repo] += [plugin]
|
|
except:
|
|
self.repoCache[repo] = [plugin]
|
|
|
|
# ----------------------------------------- #
|
|
def removeInstalledPlugin(self, key):
|
|
"""remove given plugin from the localCache"""
|
|
if key in self.localCache:
|
|
del self.localCache[key]
|
|
|
|
# ----------------------------------------- #
|
|
def removeRepository(self, repo: str):
|
|
"""remove whole repository from the repoCache"""
|
|
if repo in self.repoCache:
|
|
del self.repoCache[repo]
|
|
|
|
# ----------------------------------------- #
|
|
def getInstalledPlugin(self, key, path, readOnly):
|
|
"""get the metadata of an installed plugin"""
|
|
|
|
def metadataParser(fct):
|
|
"""plugin metadata parser reimplemented from qgis.utils
|
|
for better control of which module is examined
|
|
in case there is an installed plugin masking a core one"""
|
|
global errorDetails
|
|
cp = configparser.ConfigParser()
|
|
try:
|
|
with codecs.open(metadataFile, "r", "utf8") as f:
|
|
cp.read_file(f)
|
|
return cp.get("general", fct)
|
|
except Exception as e:
|
|
if not errorDetails:
|
|
errorDetails = e.args[0] # set to the first problem
|
|
return ""
|
|
|
|
def pluginMetadata(fct):
|
|
"""calls metadataParser for current l10n.
|
|
If failed, fallbacks to the standard metadata"""
|
|
overrideLocale = QgsSettings().value("locale/overrideFlag", False, bool)
|
|
if not overrideLocale:
|
|
locale = QLocale.system().name()
|
|
else:
|
|
locale = QgsSettings().value("locale/userLocale", "")
|
|
if locale and fct in translatableAttributes:
|
|
value = metadataParser(f"{fct}[{locale}]")
|
|
if value:
|
|
return value
|
|
value = metadataParser("{}[{}]".format(fct, locale.split("_")[0]))
|
|
if value:
|
|
return value
|
|
return metadataParser(fct)
|
|
|
|
if not QDir(path).exists():
|
|
return
|
|
|
|
global errorDetails # to communicate with the metadataParser fn
|
|
plugin = dict()
|
|
error = ""
|
|
errorDetails = ""
|
|
version = None
|
|
|
|
if not os.path.exists(os.path.join(path, "__init__.py")):
|
|
error = "broken"
|
|
errorDetails = QCoreApplication.translate(
|
|
"QgsPluginInstaller", "Missing __init__.py"
|
|
)
|
|
|
|
metadataFile = os.path.join(path, "metadata.txt")
|
|
if os.path.exists(metadataFile):
|
|
version = normalizeVersion(pluginMetadata("version"))
|
|
|
|
qt_version = int(QT_VERSION_STR.split(".")[0])
|
|
supports_qt6 = pluginMetadata("supportsQt6").strip().upper() in ("TRUE", "YES")
|
|
if (
|
|
qt_version == 6
|
|
and not supports_qt6
|
|
and "QGIS_DISABLE_SUPPORTS_QT6_CHECK" not in os.environ
|
|
):
|
|
error = "incompatible"
|
|
errorDetails = QCoreApplication.translate(
|
|
"QgsPluginInstaller", "Plugin does not support Qt6 versions of QGIS"
|
|
)
|
|
elif version:
|
|
qgisMinimumVersion = pluginMetadata("qgisMinimumVersion").strip()
|
|
if not qgisMinimumVersion:
|
|
qgisMinimumVersion = "0"
|
|
qgisMaximumVersion = pluginMetadata("qgisMaximumVersion").strip()
|
|
if not qgisMaximumVersion:
|
|
qgisMaximumVersion = qgisMinimumVersion[0] + ".99"
|
|
# if compatible, add the plugin to the list
|
|
if not isCompatible(
|
|
pyQgisVersion(), qgisMinimumVersion, qgisMaximumVersion
|
|
):
|
|
error = "incompatible"
|
|
errorDetails = f"{qgisMinimumVersion} - {qgisMaximumVersion}"
|
|
elif not os.path.exists(metadataFile):
|
|
error = "broken"
|
|
errorDetails = QCoreApplication.translate(
|
|
"QgsPluginInstaller", "Missing metadata file"
|
|
)
|
|
else:
|
|
error = "broken"
|
|
e = errorDetails
|
|
errorDetails = QCoreApplication.translate(
|
|
"QgsPluginInstaller", "Error reading metadata"
|
|
)
|
|
if e:
|
|
errorDetails += ": " + e
|
|
|
|
if not version:
|
|
version = "?"
|
|
|
|
if error[:16] == "No module named ":
|
|
mona = error.replace("No module named ", "")
|
|
if mona != key:
|
|
error = "dependent"
|
|
errorDetails = mona
|
|
|
|
icon = pluginMetadata("icon")
|
|
if QFileInfo(icon).isRelative():
|
|
icon = path + "/" + icon
|
|
|
|
changelog = pluginMetadata("changelog")
|
|
changelogFile = os.path.join(path, "CHANGELOG")
|
|
if not changelog and QFile(changelogFile).exists():
|
|
with open(changelogFile) as f:
|
|
changelog = f.read()
|
|
|
|
plugin = {
|
|
"id": key,
|
|
"plugin_id": None,
|
|
"name": pluginMetadata("name") or key,
|
|
"description": pluginMetadata("description"),
|
|
"about": pluginMetadata("about"),
|
|
"icon": icon,
|
|
"category": pluginMetadata("category"),
|
|
"tags": pluginMetadata("tags"),
|
|
"changelog": changelog,
|
|
"author_name": pluginMetadata("author_name") or pluginMetadata("author"),
|
|
"author_email": pluginMetadata("email"),
|
|
"homepage": pluginMetadata("homepage"),
|
|
"tracker": pluginMetadata("tracker"),
|
|
"code_repository": pluginMetadata("repository"),
|
|
"version_installed": version,
|
|
"library": path,
|
|
"pythonic": True,
|
|
"experimental": pluginMetadata("experimental").strip().upper()
|
|
in ["TRUE", "YES"],
|
|
"deprecated": pluginMetadata("deprecated").strip().upper()
|
|
in ["TRUE", "YES"],
|
|
"trusted": False,
|
|
"version_available": "",
|
|
"version_available_stable": "",
|
|
"version_available_experimental": "",
|
|
"zip_repository": "",
|
|
"download_url": path, # warning: local path as url!
|
|
"download_url_stable": "",
|
|
"download_url_experimental": "",
|
|
"filename": "",
|
|
"downloads": "",
|
|
"average_vote": "",
|
|
"rating_votes": "",
|
|
"create_date": pluginMetadata("create_date"),
|
|
"update_date": pluginMetadata("update_date"),
|
|
"create_date_stable": pluginMetadata("create_date_stable"),
|
|
"update_date_stable": pluginMetadata("update_date_stable"),
|
|
"create_date_experimental": pluginMetadata("create_date_experimental"),
|
|
"update_date_experimental": pluginMetadata("update_date_experimental"),
|
|
"available": False, # Will be overwritten, if any available version found.
|
|
"installed": True,
|
|
"status": "orphan", # Will be overwritten, if any available version found.
|
|
"status_exp": "orphan", # Will be overwritten, if any available version found.
|
|
"error": error,
|
|
"error_details": errorDetails,
|
|
"readonly": readOnly,
|
|
"plugin_dependencies": pluginMetadata("plugin_dependencies"),
|
|
}
|
|
return plugin
|
|
|
|
# ----------------------------------------- #
|
|
def getAllInstalled(self):
|
|
"""Build the localCache"""
|
|
self.localCache = {}
|
|
|
|
# reversed list of the plugin paths: first system plugins -> then user plugins -> finally custom path(s)
|
|
pluginPaths = list(plugin_paths)
|
|
pluginPaths.reverse()
|
|
|
|
for pluginsPath in pluginPaths:
|
|
isTheSystemDir = (
|
|
pluginPaths.index(pluginsPath) == 0
|
|
) # The current dir is the system plugins dir
|
|
if isTheSystemDir:
|
|
# temporarily add the system path as the first element to force loading the readonly plugins, even if masked by user ones.
|
|
sys.path = [pluginsPath] + sys.path
|
|
try:
|
|
pluginDir = QDir(pluginsPath)
|
|
pluginDir.setFilter(QDir.Filter.AllDirs)
|
|
for key in pluginDir.entryList():
|
|
if key not in [".", ".."]:
|
|
path = QDir.toNativeSeparators(pluginsPath + "/" + key)
|
|
# readOnly = not QFileInfo(pluginsPath).isWritable() # On windows testing the writable status isn't reliable.
|
|
readOnly = isTheSystemDir # Assume only the system plugins are not writable.
|
|
# failedToLoad = settings.value("/PythonPlugins/watchDog/" + key) is not None
|
|
plugin = self.getInstalledPlugin(
|
|
key, path=path, readOnly=readOnly
|
|
)
|
|
if (
|
|
key in list(self.localCache.keys())
|
|
and compareVersions(
|
|
self.localCache[key]["version_installed"],
|
|
plugin["version_installed"],
|
|
)
|
|
== 1
|
|
):
|
|
# An obsolete plugin in the "user" location is masking a newer one in the "system" location!
|
|
self.obsoletePlugins += [key]
|
|
self.localCache[key] = plugin
|
|
except:
|
|
# it's not necessary to stop if one of the dirs is inaccessible
|
|
pass
|
|
|
|
if isTheSystemDir:
|
|
# remove the temporarily added path
|
|
sys.path.remove(pluginsPath)
|
|
|
|
# ----------------------------------------- #
|
|
def rebuild(self):
|
|
"""build or rebuild the mPlugins from the caches"""
|
|
self.mPlugins = {}
|
|
for i in list(self.localCache.keys()):
|
|
self.mPlugins[i] = self.localCache[i].copy()
|
|
allowExperimental = (
|
|
QgsSettingsTree.node("plugin-manager")
|
|
.childSetting("allow-experimental")
|
|
.value()
|
|
)
|
|
allowDeprecated = (
|
|
QgsSettingsTree.node("plugin-manager")
|
|
.childSetting("allow-deprecated")
|
|
.value()
|
|
)
|
|
for i in list(self.repoCache.values()):
|
|
for j in i:
|
|
plugin = j.copy() # do not update repoCache elements!
|
|
key = plugin["id"]
|
|
# check if the plugin is allowed and if there isn't any better one added already.
|
|
if (
|
|
(allowExperimental or not plugin["experimental"])
|
|
and (allowDeprecated or not plugin["deprecated"])
|
|
and not (
|
|
key in self.mPlugins
|
|
and self.mPlugins[key]["version_available"]
|
|
and compareVersions(
|
|
self.mPlugins[key]["version_available"],
|
|
plugin["version_available"],
|
|
)
|
|
< 2
|
|
and self.mPlugins[key]["experimental"]
|
|
and not plugin["experimental"]
|
|
)
|
|
):
|
|
|
|
# The mPlugins dict contains now locally installed plugins.
|
|
# Now, add the available one if not present yet or update it if present already.
|
|
if key not in self.mPlugins:
|
|
self.mPlugins[key] = plugin # just add a new plugin
|
|
else:
|
|
# update local plugin with remote metadata
|
|
# description, about, icon: only use remote data if local one not available. Prefer local version because of i18n.
|
|
# NOTE: don't prefer local name to not desynchronize names if repository doesn't support i18n.
|
|
# Also prefer local icon to avoid downloading.
|
|
for attrib in translatableAttributes + ["icon"]:
|
|
if attrib != "name":
|
|
if not self.mPlugins[key][attrib] and plugin[attrib]:
|
|
self.mPlugins[key][attrib] = plugin[attrib]
|
|
# 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",
|
|
"plugin_dependencies",
|
|
"version_available_stable",
|
|
"version_available_experimental",
|
|
"download_url_stable",
|
|
"download_url_experimental",
|
|
"create_date",
|
|
"update_date",
|
|
"create_date_stable",
|
|
"update_date_stable",
|
|
"create_date_experimental",
|
|
"update_date_experimental",
|
|
]:
|
|
if (
|
|
attrib not in translatableAttributes or attrib == "name"
|
|
): # include name!
|
|
if plugin.get(attrib, False):
|
|
self.mPlugins[key][attrib] = plugin[attrib]
|
|
|
|
# If the stable version is higher than the experimental version, we ignore the experimental version
|
|
if (
|
|
compareVersions(
|
|
self.mPlugins[key]["version_available_stable"],
|
|
self.mPlugins[key]["version_available_experimental"],
|
|
)
|
|
== 1
|
|
):
|
|
self.mPlugins[key]["version_available_experimental"] = ""
|
|
|
|
# set status
|
|
#
|
|
# installed available status
|
|
# ---------------------------------------
|
|
# none any "not installed" (will be later checked if is "new")
|
|
# no none "none available"
|
|
# yes none "orphan"
|
|
# same same "installed"
|
|
# less greater "upgradeable"
|
|
# greater less "newer"
|
|
if (
|
|
not self.mPlugins[key]["version_available_stable"]
|
|
and not self.mPlugins[key]["version_installed"]
|
|
):
|
|
self.mPlugins[key]["status"] = "none available"
|
|
elif (
|
|
not self.mPlugins[key]["version_available_stable"]
|
|
and self.mPlugins[key]["version_installed"]
|
|
):
|
|
self.mPlugins[key]["status"] = "orphan"
|
|
elif not self.mPlugins[key]["version_installed"]:
|
|
self.mPlugins[key]["status"] = "not installed"
|
|
elif self.mPlugins[key]["version_installed"] in ["?", "-1"]:
|
|
self.mPlugins[key]["status"] = "installed"
|
|
elif (
|
|
compareVersions(
|
|
self.mPlugins[key]["version_available_stable"],
|
|
self.mPlugins[key]["version_installed"],
|
|
)
|
|
== 0
|
|
):
|
|
self.mPlugins[key]["status"] = "installed"
|
|
elif (
|
|
compareVersions(
|
|
self.mPlugins[key]["version_available_stable"],
|
|
self.mPlugins[key]["version_installed"],
|
|
)
|
|
== 1
|
|
):
|
|
self.mPlugins[key]["status"] = "upgradeable"
|
|
else:
|
|
self.mPlugins[key]["status"] = "newer"
|
|
# debug: test if the status match the "installed" tag:
|
|
if (
|
|
self.mPlugins[key]["status"]
|
|
in ["not installed", "none available"]
|
|
and self.mPlugins[key]["installed"]
|
|
):
|
|
raise Exception(
|
|
f"Error: plugin status is ambiguous (1) for plugin {key}"
|
|
)
|
|
if (
|
|
self.mPlugins[key]["status"]
|
|
in ["installed", "orphan", "upgradeable", "newer"]
|
|
and not self.mPlugins[key]["installed"]
|
|
):
|
|
raise Exception(
|
|
f"Error: plugin status is ambiguous (2) for plugin {key}"
|
|
)
|
|
|
|
if (
|
|
not self.mPlugins[key]["version_available_experimental"]
|
|
and not self.mPlugins[key]["version_installed"]
|
|
):
|
|
self.mPlugins[key]["status_exp"] = "none available"
|
|
elif (
|
|
not self.mPlugins[key]["version_available_experimental"]
|
|
and self.mPlugins[key]["version_installed"]
|
|
):
|
|
self.mPlugins[key]["status_exp"] = "orphan"
|
|
elif not self.mPlugins[key]["version_installed"]:
|
|
self.mPlugins[key]["status_exp"] = "not installed"
|
|
elif self.mPlugins[key]["version_installed"] in ["?", "-1"]:
|
|
self.mPlugins[key]["status_exp"] = "installed"
|
|
elif (
|
|
compareVersions(
|
|
self.mPlugins[key]["version_available_experimental"],
|
|
self.mPlugins[key]["version_installed"],
|
|
)
|
|
== 0
|
|
):
|
|
self.mPlugins[key]["status_exp"] = "installed"
|
|
elif (
|
|
compareVersions(
|
|
self.mPlugins[key]["version_available_experimental"],
|
|
self.mPlugins[key]["version_installed"],
|
|
)
|
|
== 1
|
|
):
|
|
self.mPlugins[key]["status_exp"] = "upgradeable"
|
|
else:
|
|
self.mPlugins[key]["status_exp"] = "newer"
|
|
# debug: test if the status_exp match the "installed" tag:
|
|
if (
|
|
self.mPlugins[key]["status_exp"]
|
|
in ["not installed", "none available"]
|
|
and self.mPlugins[key]["installed"]
|
|
):
|
|
raise Exception(
|
|
f"Error: plugin status_exp is ambiguous (1) for plugin {key}"
|
|
)
|
|
if (
|
|
self.mPlugins[key]["status_exp"]
|
|
in ["installed", "orphan", "upgradeable", "newer"]
|
|
and not self.mPlugins[key]["installed"]
|
|
):
|
|
raise Exception(
|
|
"Error: plugin status_exp is ambiguous (2) for plugin {} (status_exp={})".format(
|
|
key, self.mPlugins[key]["status_exp"]
|
|
)
|
|
)
|
|
|
|
self.markNews()
|
|
|
|
# ----------------------------------------- #
|
|
def markNews(self):
|
|
"""mark all new plugins as new"""
|
|
seenPlugins = (
|
|
QgsSettingsTree.node("plugin-manager")
|
|
.childSetting("seen-plugins")
|
|
.valueWithDefaultOverride(list(self.mPlugins.keys()))
|
|
)
|
|
if len(seenPlugins) > 0:
|
|
for plugin in list(self.mPlugins.keys()):
|
|
if (
|
|
seenPlugins.count(plugin) == 0
|
|
and self.mPlugins[plugin]["status"] == "not installed"
|
|
):
|
|
self.mPlugins[plugin]["status"] = "new"
|
|
|
|
# ----------------------------------------- #
|
|
def updateSeenPluginsList(self):
|
|
"""update the list of all seen plugins"""
|
|
setting = QgsSettingsTree.node("plugin-manager").childSetting("seen-plugins")
|
|
seenPlugins = setting.valueWithDefaultOverride(list(self.mPlugins.keys()))
|
|
for plugin in list(self.mPlugins.keys()):
|
|
if seenPlugins.count(plugin) == 0:
|
|
seenPlugins += [plugin]
|
|
setting.setValue(seenPlugins)
|
|
|
|
# ----------------------------------------- #
|
|
def isThereAnythingNew(self):
|
|
"""return true if an upgradeable or new plugin detected"""
|
|
for i in list(self.mPlugins.values()):
|
|
if i["status"] in ["upgradeable", "new"]:
|
|
return True
|
|
return False
|
|
|
|
|
|
# --- /class Plugins --------------------------------------------------------------------- #
|
|
|
|
|
|
# public instances:
|
|
repositories = Repositories()
|
|
plugins = Plugins()
|