From 3981f79a3499a35ce0d9f16c723e5c512f752c28 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20Wei=C3=9Fschuh?= Date: Sat, 14 Nov 2020 15:13:00 +0100 Subject: [PATCH] load plugins from system-wide directory https://www.mobileread.com/forums/showthread.php?t=333893 --- src/calibre/constants.py | 2 + src/calibre/customize/__init__.py | 12 +++- src/calibre/customize/ui.py | 78 +++++++++++++++++----- src/calibre/gui2/dialogs/plugin_updater.py | 8 ++- src/calibre/gui2/preferences/plugins.py | 3 + src/calibre/gui2/ui.py | 15 +++-- 6 files changed, 94 insertions(+), 24 deletions(-) diff --git a/src/calibre/constants.py b/src/calibre/constants.py index aa6f4daa99..727dff8be2 100644 --- a/src/calibre/constants.py +++ b/src/calibre/constants.py @@ -166,6 +166,8 @@ def cache_dir(): # plugins {{{ plugins_loc = sys.extensions_location +system_plugins_loc = getattr(sys, 'system_plugins_location', None) + from importlib.machinery import ModuleSpec, EXTENSION_SUFFIXES, ExtensionFileLoader from importlib.util import find_spec from importlib import import_module diff --git a/src/calibre/customize/__init__.py b/src/calibre/customize/__init__.py index bc3d4fef92..b3b1b39ea1 100644 --- a/src/calibre/customize/__init__.py +++ b/src/calibre/customize/__init__.py @@ -2,7 +2,7 @@ __license__ = 'GPL v3' __copyright__ = '2008, Kovid Goyal ' -import os, sys, zipfile, importlib +import os, sys, zipfile, importlib, enum from calibre.constants import numeric_version, iswindows, ismacos from calibre.ptempfile import PersistentTemporaryFile @@ -24,10 +24,17 @@ class InvalidPlugin(ValueError): pass +class PluginInstallationType(enum.Enum): + BUILTIN = enum.auto() + SYSTEM = enum.auto() + EXTERNAL = enum.auto() + + class Plugin(object): # {{{ ''' A calibre plugin. Useful members include: + * ``self.installation_type``: Stores how the plugin was installed. * ``self.plugin_path``: Stores path to the ZIP file that contains this plugin or None if it is a builtin plugin @@ -73,6 +80,9 @@ class Plugin(object): # {{{ #: The earliest version of calibre this plugin requires minimum_calibre_version = (0, 4, 118) + #: The way this plugin is installed + installation_type = None + #: If False, the user will not be able to disable this plugin. Use with #: care. can_be_disabled = True diff --git a/src/calibre/customize/ui.py b/src/calibre/customize/ui.py index e1b31a75ea..ee49a18235 100644 --- a/src/calibre/customize/ui.py +++ b/src/calibre/customize/ui.py @@ -4,14 +4,14 @@ __copyright__ = '2008, Kovid Goyal ' import os, shutil, traceback, functools, sys from collections import defaultdict -from itertools import chain +from itertools import chain, repeat from calibre.customize import (CatalogPlugin, FileTypePlugin, PluginNotFound, MetadataReaderPlugin, MetadataWriterPlugin, InterfaceActionBase as InterfaceAction, PreferencesPlugin, platform, InvalidPlugin, StoreBase as Store, EditBookToolPlugin, - LibraryClosedPlugin) + LibraryClosedPlugin, PluginInstallationType) from calibre.customize.conversion import InputFormatPlugin, OutputFormatPlugin from calibre.customize.zipplugin import loader from calibre.customize.profiles import InputProfile, OutputProfile @@ -21,13 +21,17 @@ from calibre.ebooks.metadata import MetaInformation from calibre.utils.config import (make_config_dir, Config, ConfigProxy, plugin_dir, OptionParser) from calibre.ebooks.metadata.sources.base import Source -from calibre.constants import DEBUG, numeric_version +from calibre.constants import DEBUG, numeric_version, system_plugins_loc from polyglot.builtins import iteritems, itervalues, unicode_type builtin_names = frozenset(p.name for p in builtin_plugins) BLACKLISTED_PLUGINS = frozenset({'Marvin XD', 'iOS reader applications'}) +def zip_value(iterable, value): + return zip(iterable, repeat(value)) + + class NameConflict(ValueError): pass @@ -341,10 +345,19 @@ def reread_metadata_plugins(): for ft in plugin.file_types: _metadata_writers[ft].append(plugin) - # Ensure custom metadata plugins are used in preference to builtin - # ones for a given filetype + # Ensure the following metadata plugin preference is used: + # external > system > builtin def key(plugin): - return (1 if plugin.plugin_path is None else 0), plugin.name + installation_type = plugin.installation_type + if installation_type is PluginInstallationType.BUILTIN: + order = 2 + elif installation_type is PluginInstallationType.SYSTEM: + order = 1 + elif installation_type is PluginInstallationType.EXTERNAL: + order = 0 + else: + raise ValueError(installation_type) + return order, plugin.name for group in (_metadata_readers, _metadata_writers): for plugins in itervalues(group): @@ -473,7 +486,10 @@ def add_plugin(path_to_zip_file): if plugin.name in builtin_names: raise NameConflict( 'A builtin plugin with the name %r already exists' % plugin.name) - plugin = initialize_plugin(plugin, path_to_zip_file) + if plugin.name in _list_system_plugins(): + raise NameConflict( + 'A system plugin with the name %r already exists' % plugin.name) + plugin = initialize_plugin(plugin, path_to_zip_file, PluginInstallationType.EXTERNAL) plugins = config['plugins'] zfp = os.path.join(plugin_dir, plugin.name+'.zip') if os.path.exists(zfp): @@ -659,9 +675,10 @@ def all_edit_book_tool_plugins(): _initialized_plugins = [] -def initialize_plugin(plugin, path_to_zip_file): +def initialize_plugin(plugin, path_to_zip_file, installation_type): try: p = plugin(path_to_zip_file) + p.installation_type = installation_type p.initialize() return p except Exception: @@ -676,36 +693,67 @@ def has_external_plugins(): return bool(config['plugins']) +@functools.lru_cache(maxsize=None) +def _list_system_plugins(): + if not system_plugins_loc: + return + + try: + plugin_file_names = os.listdir(system_plugins_loc) + except OSError: + return + + for plugin_file_name in plugin_file_names: + plugin_path = os.path.join(system_plugins_loc, plugin_file_name) + if os.path.isfile(plugin_path) and plugin_file_name.endswith('.zip'): + yield (os.path.splitext(plugin_file_name)[0], plugin_path) + + def initialize_plugins(perf=False): - global _initialized_plugins + global _initialized_plugins, _system_plugins _initialized_plugins = [] + _system_plugins = [] + system_plugins = dict(_list_system_plugins()) conflicts = [name for name in config['plugins'] if name in - builtin_names] + builtin_names or name in system_plugins] for p in conflicts: remove_plugin(p) + system_conflicts = [name for name in system_plugins if name in + builtin_names] + for p in system_conflicts: + system_plugins.pop(p, None) external_plugins = config['plugins'].copy() for name in BLACKLISTED_PLUGINS: external_plugins.pop(name, None) + system_plugins.pop(name, None) ostdout, ostderr = sys.stdout, sys.stderr if perf: from collections import defaultdict import time times = defaultdict(lambda:0) - for zfp in list(external_plugins) + builtin_plugins: + + for zfp, installation_type in chain( + zip_value(external_plugins.items(), PluginInstallationType.EXTERNAL), + zip_value(system_plugins.items(), PluginInstallationType.SYSTEM), + zip_value(builtin_plugins, PluginInstallationType.BUILTIN), + ): try: if not isinstance(zfp, type): # We have a plugin name - pname = zfp - zfp = os.path.join(plugin_dir, zfp+'.zip') + pname, path = zfp + zfp = os.path.join(plugin_dir, pname+'.zip') if not os.path.exists(zfp): - zfp = external_plugins[pname] + zfp = path try: plugin = load_plugin(zfp) if not isinstance(zfp, type) else zfp except PluginNotFound: continue if perf: st = time.time() - plugin = initialize_plugin(plugin, None if isinstance(zfp, type) else zfp) + plugin = initialize_plugin( + plugin, + None if isinstance(zfp, type) else zfp, installation_type, + ) if perf: times[plugin.name] = time.time() - st _initialized_plugins.append(plugin) diff --git a/src/calibre/gui2/dialogs/plugin_updater.py b/src/calibre/gui2/dialogs/plugin_updater.py index 522cc77d58..77ed0c95ae 100644 --- a/src/calibre/gui2/dialogs/plugin_updater.py +++ b/src/calibre/gui2/dialogs/plugin_updater.py @@ -20,6 +20,7 @@ from calibre import prints from calibre.constants import ( DEBUG, __appname__, __version__, ismacos, iswindows, numeric_version ) +from calibre.customize import PluginInstallationType from calibre.customize.ui import ( NameConflict, add_plugin, disable_plugin, enable_plugin, has_external_plugins, initialized_plugins, is_disabled, remove_plugin @@ -56,7 +57,8 @@ def get_plugin_updates_available(raise_error=False): def filter_upgradeable_plugins(display_plugin): - return display_plugin.is_upgrade_available() + return display_plugin.installation_type is PluginInstallationType.EXTERNAL \ + and display_plugin.is_upgrade_available() def filter_not_installed_plugins(display_plugin): @@ -96,7 +98,8 @@ def get_installed_plugin_status(display_plugin): display_plugin.installed_version = None display_plugin.plugin = None for plugin in initialized_plugins(): - if plugin.name == display_plugin.qname and plugin.plugin_path is not None: + if plugin.name == display_plugin.qname \ + and plugin.installation_type is not PluginInstallationType.BUILTIN: display_plugin.plugin = plugin display_plugin.installed_version = plugin.version break @@ -195,6 +198,7 @@ class DisplayPlugin(object): self.uninstall_plugins = plugin['uninstall'] or [] self.has_changelog = plugin['history'] self.is_deprecated = plugin['deprecated'] + self.installation_type = plugin['installation_type'] def is_disabled(self): if self.plugin is None: diff --git a/src/calibre/gui2/preferences/plugins.py b/src/calibre/gui2/preferences/plugins.py index c3dc31b40c..c6b635c5c8 100644 --- a/src/calibre/gui2/preferences/plugins.py +++ b/src/calibre/gui2/preferences/plugins.py @@ -14,6 +14,7 @@ from qt.core import (Qt, QModelIndex, QAbstractItemModel, QIcon, from calibre.gui2.preferences import ConfigWidgetBase, test_widget from calibre.gui2.preferences.plugins_ui import Ui_Form +from calibre.customize import PluginInstallationType from calibre.customize.ui import (initialized_plugins, is_disabled, enable_plugin, disable_plugin, plugin_customization, add_plugin, remove_plugin, NameConflict) @@ -211,6 +212,8 @@ class PluginModel(QAbstractItemModel, AdaptSQP): # {{{ ans += _('\nCustomization: ')+c if disabled: ans += _('\n\nThis plugin has been disabled') + if plugin.installation_type is PluginInstallationType.SYSTEM: + ans += _('\n\nThis plugin is installed system-wide and can not be managed from within calibre') return (ans) if role == Qt.ItemDataRole.DecorationRole: return self.disabled_icon if disabled else self.icon diff --git a/src/calibre/gui2/ui.py b/src/calibre/gui2/ui.py index 4ad7f456d4..6e51eb5a9f 100644 --- a/src/calibre/gui2/ui.py +++ b/src/calibre/gui2/ui.py @@ -28,6 +28,7 @@ from calibre import detect_ncpus, force_unicode, prints from calibre.constants import ( DEBUG, __appname__, config_dir, filesystem_encoding, ismacos, iswindows ) +from calibre.customize import PluginInstallationType from calibre.customize.ui import available_store_plugins, interface_actions from calibre.db.legacy import LibraryDatabase from calibre.gui2 import ( @@ -127,7 +128,8 @@ class Main(MainWindow, MainWindowMixin, DeviceMixin, EmailMixin, # {{{ self.iactions = OrderedDict() # Actions for action in interface_actions(): - if opts.ignore_plugins and action.plugin_path is not None: + if opts.ignore_plugins \ + and action.installation_type is not PluginInstallationType.BUILTIN: continue try: ac = self.init_iaction(action) @@ -139,7 +141,7 @@ class Main(MainWindow, MainWindowMixin, DeviceMixin, EmailMixin, # {{{ except Exception: if action.plugin_path: print('Failed to load Interface Action plugin:', action.plugin_path, file=sys.stderr) - if action.plugin_path is None: + if action.installation_type is PluginInstallationType.BUILTIN: raise continue ac.plugin_path = action.plugin_path @@ -166,7 +168,8 @@ class Main(MainWindow, MainWindowMixin, DeviceMixin, EmailMixin, # {{{ from calibre.gui2.store.loader import Stores self.istores = Stores() for store in available_store_plugins(): - if self.opts.ignore_plugins and store.plugin_path is not None: + if self.opts.ignore_plugins \ + and store.installation_type is not PluginInstallationType.BUILTIN: continue try: st = self.init_istore(store) @@ -175,7 +178,7 @@ class Main(MainWindow, MainWindowMixin, DeviceMixin, EmailMixin, # {{{ # Ignore errors in loading user supplied plugins import traceback traceback.print_exc() - if store.plugin_path is None: + if store.installation_type is PluginInstallationType.BUILTIN: raise continue self.istores.builtins_loaded() @@ -369,7 +372,7 @@ class Main(MainWindow, MainWindowMixin, DeviceMixin, EmailMixin, # {{{ except: import traceback traceback.print_exc() - if ac.plugin_path is None: + if ac.installation_type is PluginInstallationType.BUILTIN: raise if config['autolaunch_server']: @@ -389,7 +392,7 @@ class Main(MainWindow, MainWindowMixin, DeviceMixin, EmailMixin, # {{{ except: import traceback traceback.print_exc() - if ac.plugin_path is None: + if ac.installation_type is PluginInstallationType.BUILTIN: raise self.set_current_library_information(current_library_name(), db.library_id, db.field_metadata)