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 }