File indexing completed on 2024-12-22 04:15:08

0001 /*
0002  * This file is part of PyKrita, Krita' Python scripting plugin.
0003  *
0004  * SPDX-FileCopyrightText: 2013 Alex Turbov <i.zaufi@gmail.com>
0005  * SPDX-FileCopyrightText: 2014-2016 Boudewijn Rempt <boud@valdyas.org>
0006  * SPDX-FileCopyrightText: 2017 Jouni Pentikäinen (joupent@gmail.com)
0007  *
0008  * SPDX-License-Identifier: LGPL-2.0-or-later
0009  */
0010 
0011 #include "PythonPluginManager.h"
0012 
0013 #include <QFile>
0014 #include <QFileInfo>
0015 #include <KoResourcePaths.h>
0016 #include <KConfigCore/KConfig>
0017 #include <KConfigCore/KDesktopFile>
0018 #include <KI18n/KLocalizedString>
0019 #include <KConfigCore/KSharedConfig>
0020 #include <KConfigCore/KConfigGroup>
0021 
0022 #include <KisUsageLogger.h>
0023 
0024 #include "config.h"
0025 #include "version_checker.h"
0026 
0027 static QString currentLocale()
0028 {
0029     const QStringList languages = KLocalizedString::languages();
0030     if (languages.isEmpty()) {
0031         return QLocale().name();
0032     } else {
0033         return languages.first();
0034     }
0035 }
0036 
0037 PythonPluginManager* instance = 0;
0038 
0039 // PythonPlugin implementation
0040 
0041 QString PythonPlugin::moduleFilePathPart() const
0042 {
0043     QString filePath = m_moduleName;
0044     return filePath.replace(".", "/");
0045 }
0046 
0047 bool PythonPlugin::isValid() const
0048 {
0049     dbgScript << "Got Krita/PythonPlugin: " << name()
0050               << ", module-path=" << moduleName()
0051                 ;
0052     // Make sure mandatory properties are here
0053     if (m_name.isEmpty()) {
0054         dbgScript << "Ignore desktop file w/o a name";
0055         return false;
0056     }
0057     if (m_moduleName.isEmpty()) {
0058         dbgScript << "Ignore desktop file w/o a module to import";
0059         return false;
0060     }
0061 #if PY_MAJOR_VERSION == 2
0062     // Check if the plug-in is compatible with Python 2 or not.
0063     if (m_properties["X-Python-2-Compatible"].toBool() != true) {
0064         dbgScript << "Ignoring plug-in. It is marked incompatible with Python 2.";
0065         return false;
0066     }
0067 #endif
0068 
0069     return true;
0070 }
0071 
0072 // PythonPluginManager implementation
0073 
0074 PythonPluginManager::PythonPluginManager()
0075         : QObject(0)
0076         , m_model(0, this)
0077 {}
0078 
0079 const QList<PythonPlugin>& PythonPluginManager::plugins() const
0080 {
0081     return m_plugins;
0082 }
0083 
0084 PythonPlugin * PythonPluginManager::plugin(int index) {
0085     if (index >= 0 && index < m_plugins.count()) {
0086         return &m_plugins[index];
0087     }
0088 
0089     return nullptr;
0090 }
0091 
0092 PythonPluginsModel * PythonPluginManager::model()
0093 {
0094     return &m_model;
0095 }
0096 
0097 void PythonPluginManager::unloadAllModules()
0098 {
0099     Q_FOREACH(PythonPlugin plugin, m_plugins) {
0100         if (plugin.m_loaded) {
0101             unloadModule(plugin);
0102         }
0103     }
0104 }
0105 
0106 bool PythonPluginManager::verifyModuleExists(PythonPlugin &plugin)
0107 {
0108     // Find the module:
0109     // 0) try to locate directory based plugin first
0110     QString rel_path = plugin.moduleFilePathPart();
0111     rel_path = rel_path + "/" + "__init__.py";
0112     dbgScript << "Finding Python module with rel_path:" << rel_path;
0113 
0114     QString module_path = KoResourcePaths::findAsset("pythonscripts", rel_path);
0115 
0116     dbgScript << "module_path:" << module_path;
0117 
0118     if (module_path.isEmpty()) {
0119         // 1) Nothing found, then try file based plugin
0120         rel_path = plugin.moduleFilePathPart() + ".py";
0121         dbgScript << "Finding Python module with rel_path:" << rel_path;
0122         module_path = KoResourcePaths::findAsset("pythonscripts", rel_path);
0123         dbgScript << "module_path:" << module_path;
0124     }
0125 
0126     // Is anything found at all?
0127     if (module_path.isEmpty()) {
0128         plugin.m_broken = true;
0129         plugin.m_errorReason = i18nc(
0130                                    "@info:tooltip"
0131                                    , "Unable to find the module specified <application>%1</application>"
0132                                    , plugin.moduleName()
0133                                );
0134         dbgScript << "Cannot load module:" << plugin.m_errorReason;
0135         return false;
0136     }
0137     dbgScript << "Found module path:" << module_path;
0138     return true;
0139 }
0140 
0141 QPair<QString, PyKrita::version_checker> PythonPluginManager::parseDependency(const QString& d)
0142 {
0143     // Check if dependency has package info attached
0144     const int pnfo = d.indexOf('(');
0145     if (pnfo != -1) {
0146         QString dependency = d.mid(0, pnfo);
0147         QString version_str = d.mid(pnfo + 1, d.size() - pnfo - 2).trimmed();
0148         dbgScript << "Desired version spec [" << dependency << "]:" << version_str;
0149         PyKrita::version_checker checker = PyKrita::version_checker::fromString(version_str);
0150         if (!(checker.isValid() && d.endsWith(')'))) {
0151             dbgScript << "Invalid version spec " << d;
0152             QString reason = i18nc(
0153                                  "@info:tooltip"
0154                                  , "<p>Specified version has invalid format for dependency <application>%1</application>: "
0155                                  "<icode>%2</icode>. Skipped</p>"
0156                                  , dependency
0157                                  , version_str
0158                              );
0159             return qMakePair(reason, PyKrita::version_checker());
0160         }
0161         return qMakePair(dependency, checker);
0162     }
0163     return qMakePair(d, PyKrita::version_checker(PyKrita::version_checker::undefined));
0164 }
0165 
0166 /**
0167  * Collect dependencies and check them. To do it
0168  * just try to import a module... when unload it ;)
0169  *
0170  * \c X-Python-Dependencies property of \c .desktop file has the following format:
0171  * <tt>python-module(version-info)</tt>, where <tt>python-module</tt>
0172  * a python module name to be imported, <tt>version-spec</tt>
0173  * is a version triplet delimited by dots, possible w/ leading compare
0174  * operator: \c =, \c <, \c >, \c <=, \c >=
0175  */
0176 void PythonPluginManager::verifyDependenciesSetStatus(PythonPlugin& plugin)
0177 {
0178     QStringList dependencies = plugin.property("X-Python-Dependencies").toStringList();
0179 
0180     PyKrita::Python py = PyKrita::Python();
0181     QString reason = i18nc("@info:tooltip", "<title>Dependency check</title>");
0182     Q_FOREACH(const QString & d, dependencies) {
0183         QPair<QString, PyKrita::version_checker> info_pair = parseDependency(d);
0184         PyKrita::version_checker& checker = info_pair.second;
0185         if (!checker.isValid()) {
0186             plugin.m_broken = true;
0187             reason += info_pair.first;
0188             continue;
0189         }
0190 
0191         dbgScript << "Try to import dependency module/package:" << d;
0192 
0193         // Try to import a module
0194         const QString& dependency = info_pair.first;
0195         PyObject* module = py.moduleImport(PQ(dependency));
0196         if (module) {
0197             if (checker.isEmpty()) {                        // Need to check smth?
0198                 dbgScript << "No version to check, just make sure it's loaded:" << dependency;
0199                 Py_DECREF(module);
0200                 continue;
0201             }
0202             // Try to get __version__ from module
0203             // See PEP396: https://www.python.org/dev/peps/pep-0396/
0204             PyObject* version_obj = py.itemString("__version__", PQ(dependency));
0205             if (!version_obj) {
0206                 dbgScript << "No __version__ for " << dependency
0207                           << "[" << plugin.name() << "]:\n" << py.lastTraceback()
0208                           ;
0209                 plugin.m_unstable = true;
0210                 reason += i18nc(
0211                               "@info:tooltip"
0212                               , "<p>Failed to check version of dependency <application>%1</application>: "
0213                               "Module do not have PEP396 <code>__version__</code> attribute. "
0214                               "It is not disabled, but behaviour is unpredictable...</p>"
0215                               , dependency
0216                           );
0217             }
0218             PyKrita::version dep_version = PyKrita::version::fromPythonObject(version_obj);
0219 
0220             if (!dep_version.isValid()) {
0221                 // Dunno what is this... Giving up!
0222                 dbgScript << "***: Can't parse module version for" << dependency;
0223                 plugin.m_unstable = true;
0224                 reason += i18nc(
0225                               "@info:tooltip"
0226                               , "<p><application>%1</application>: Unexpected module's version format"
0227                               , dependency
0228                           );
0229             } else if (!checker(dep_version)) {
0230                 dbgScript << "Version requirement check failed ["
0231                           << plugin.name() << "] for "
0232                           << dependency << ": wanted " << checker.operationToString()
0233                           << QString(checker.required())
0234                           << ", but found" << QString(dep_version)
0235                           ;
0236                 plugin.m_broken = true;
0237                 reason += i18nc(
0238                               "@info:tooltip"
0239                               , "<p><application>%1</application>: No suitable version found. "
0240                               "Required version %2 %3, but found %4</p>"
0241                               , dependency
0242                               , checker.operationToString()
0243                               , QString(checker.required())
0244                               , QString(dep_version)
0245                           );
0246             }
0247             // Do not need this module anymore...
0248             Py_DECREF(module);
0249         } else {
0250             dbgScript << "Load failure [" << plugin.name() << "]:\n" << py.lastTraceback();
0251             plugin.m_broken = true;
0252             reason += i18nc(
0253                           "@info:tooltip"
0254                           , "<p>Failure on module load <application>%1</application>:</p><pre>%2</pre>"
0255                           , dependency
0256                           , py.lastTraceback()
0257                       );
0258         }
0259     }
0260 
0261     if (plugin.isBroken() || plugin.isUnstable()) {
0262         plugin.m_errorReason = reason;
0263     }
0264 }
0265 
0266 void PythonPluginManager::scanPlugins()
0267 {
0268     m_plugins.clear();
0269 
0270     KConfigGroup pluginSettings(KSharedConfig::openConfig(), "python");
0271 
0272     QStringList desktopFiles = KoResourcePaths::findAllAssets("data", "pykrita/*desktop");
0273 
0274     Q_FOREACH(const QString &desktopFile, desktopFiles) {
0275 
0276         KDesktopFile df(desktopFile);
0277         df.setLocale(currentLocale());
0278         const KConfigGroup dg = df.desktopGroup();
0279         if (dg.readEntry("ServiceTypes") == "Krita/PythonPlugin") {
0280             PythonPlugin plugin;
0281             plugin.m_comment = df.readComment();
0282             plugin.m_name = df.readName();
0283             plugin.m_moduleName = dg.readEntry("X-KDE-Library");
0284             plugin.m_properties["X-Python-2-Compatible"] = dg.readEntry("X-Python-2-Compatible", false);
0285 
0286             QString manual = dg.readEntry("X-Krita-Manual");
0287             if (!manual.isEmpty()) {
0288                 QFile f(QFileInfo(desktopFile).path() + "/" + plugin.m_moduleName + "/" + manual);
0289                 if (f.exists()) {
0290                     f.open(QFile::ReadOnly);
0291                     QByteArray ba = f.readAll();
0292                     f.close();
0293                     plugin.m_manual = QString::fromUtf8(ba);
0294                 }
0295             }
0296             if (!plugin.isValid()) {
0297                 dbgScript << plugin.name() << "is not usable";
0298                 continue;
0299             }
0300 
0301             if (!verifyModuleExists(plugin)) {
0302                 dbgScript << "Cannot load" << plugin.name() << ": broken"
0303                           << plugin.isBroken()
0304                           << "because:" << plugin.errorReason();
0305                 continue;
0306             }
0307 
0308             verifyDependenciesSetStatus(plugin);
0309 
0310             plugin.m_enabled = pluginSettings.readEntry(QString("enable_") + plugin.moduleName(), false);
0311 
0312             m_plugins.append(plugin);
0313         }
0314     }
0315 }
0316 
0317 void PythonPluginManager::tryLoadEnabledPlugins()
0318 {
0319     KisUsageLogger::writeSysInfo("Loaded Python Plugins");
0320     for (PythonPlugin &plugin : m_plugins) {
0321         dbgScript << "Trying to load plugin" << plugin.moduleName()
0322                   << ". Enabled:" << plugin.isEnabled()
0323                   << ". Broken: " << plugin.isBroken();
0324 
0325         if (plugin.m_enabled && !plugin.isBroken()) {
0326             loadModule(plugin);
0327         }
0328     }
0329     KisUsageLogger::writeSysInfo("\n");
0330 }
0331 
0332 void PythonPluginManager::loadModule(PythonPlugin &plugin)
0333 {
0334     KIS_SAFE_ASSERT_RECOVER_RETURN(plugin.isEnabled() && !plugin.isBroken());
0335 
0336     QString module_name = plugin.moduleName();
0337     KisUsageLogger::writeSysInfo("\t" + module_name);
0338     dbgScript << "Loading module: " << module_name;
0339 
0340     PyKrita::Python py = PyKrita::Python();
0341 
0342     // Get 'plugins' key from 'pykrita' module dictionary.
0343     // Every entry has a module name as a key and 2 elements tuple as a value
0344     PyObject* plugins = py.itemString("plugins");
0345     KIS_SAFE_ASSERT_RECOVER_RETURN(plugins);
0346 
0347     PyObject* module = py.moduleImport(PQ(module_name));
0348     if (module) {
0349         // Move just loaded module to the dict
0350         const int ins_result = PyDict_SetItemString(plugins, PQ(module_name), module);
0351         KIS_SAFE_ASSERT_RECOVER_NOOP(ins_result == 0);
0352         Py_DECREF(module);
0353         // Handle failure in release mode.
0354         if (ins_result == 0) {
0355             // Initialize the module from Python's side
0356             PyObject* const args = Py_BuildValue("(s)", PQ(module_name));
0357             PyObject* result = py.functionCall("_pluginLoaded", PyKrita::Python::PYKRITA_ENGINE, args);
0358             Py_DECREF(args);
0359             if (result) {
0360                 dbgScript << "\t" << "success!";
0361                 plugin.m_loaded = true;
0362                 return;
0363             }
0364         }
0365         plugin.m_errorReason = i18nc("@info:tooltip", "Internal engine failure");
0366     } else {
0367         plugin.m_errorReason = i18nc(
0368                                    "@info:tooltip"
0369                                    , "Module not loaded:<br/>%1"
0370                                    , py.lastTraceback().replace("\n", "<br/>")
0371                                );
0372     }
0373     plugin.m_broken = true;
0374     warnScript << "Error loading plugin" << module_name;
0375 }
0376 
0377 void PythonPluginManager::unloadModule(PythonPlugin &plugin)
0378 {
0379     KIS_SAFE_ASSERT_RECOVER_RETURN(plugin.m_loaded);
0380     KIS_SAFE_ASSERT_RECOVER_RETURN(!plugin.isBroken());
0381 
0382     dbgScript << "Unloading module: " << plugin.moduleName();
0383 
0384     PyKrita::Python py = PyKrita::Python();
0385 
0386     // Get 'plugins' key from 'pykrita' module dictionary
0387     PyObject* plugins = py.itemString("plugins");
0388     KIS_SAFE_ASSERT_RECOVER_RETURN(plugins);
0389 
0390     PyObject* const args = Py_BuildValue("(s)", PQ(plugin.moduleName()));
0391     py.functionCall("_pluginUnloading", PyKrita::Python::PYKRITA_ENGINE, args);
0392     Py_DECREF(args);
0393 
0394     // This will just decrement a reference count for module instance
0395     PyDict_DelItemString(plugins, PQ(plugin.moduleName()));
0396 
0397     // Remove the module also from 'sys.modules' dict to really unload it,
0398     // so if reloaded all @init actions will work again!
0399     PyObject* sys_modules = py.itemString("modules", "sys");
0400     KIS_SAFE_ASSERT_RECOVER_RETURN(sys_modules);
0401     PyDict_DelItemString(sys_modules, PQ(plugin.moduleName()));
0402 
0403     plugin.m_loaded = false;
0404 }
0405 
0406 void PythonPluginManager::setPluginEnabled(PythonPlugin &plugin, bool enabled)
0407 {
0408     bool wasEnabled = plugin.isEnabled();
0409 
0410     if (wasEnabled && !enabled) {
0411         unloadModule(plugin);
0412     }
0413 
0414     plugin.m_enabled = enabled;
0415     KConfigGroup pluginSettings(KSharedConfig::openConfig(), "python");
0416     pluginSettings.writeEntry(QString("enable_") + plugin.moduleName(), enabled);
0417 
0418     if (!wasEnabled && enabled) {
0419         loadModule(plugin);
0420     }
0421 }