diff --git a/src/calibre/customize/__init__.py b/src/calibre/customize/__init__.py index 67cf822a17..faa6fcada2 100644 --- a/src/calibre/customize/__init__.py +++ b/src/calibre/customize/__init__.py @@ -4,9 +4,22 @@ __copyright__ = '2008, Kovid Goyal ' import os, sys, zipfile, importlib -from calibre.constants import numeric_version +from calibre.constants import numeric_version, iswindows, isosx from calibre.ptempfile import PersistentTemporaryFile +platform = 'linux' +if iswindows: + platform = 'windows' +elif isosx: + platform = 'osx' + + +class PluginNotFound(ValueError): + pass + +class InvalidPlugin(ValueError): + pass + class Plugin(object): # {{{ ''' diff --git a/src/calibre/customize/ui.py b/src/calibre/customize/ui.py index 0f5508a89e..9c8f80544b 100644 --- a/src/calibre/customize/ui.py +++ b/src/calibre/customize/ui.py @@ -2,17 +2,16 @@ from __future__ import with_statement __license__ = 'GPL v3' __copyright__ = '2008, Kovid Goyal ' -import os, shutil, traceback, functools, sys, re -from contextlib import closing +import os, shutil, traceback, functools, sys -from calibre.customize import Plugin, CatalogPlugin, FileTypePlugin, \ - MetadataReaderPlugin, MetadataWriterPlugin, \ - InterfaceActionBase as InterfaceAction, \ - PreferencesPlugin +from calibre.customize import (CatalogPlugin, FileTypePlugin, PluginNotFound, + MetadataReaderPlugin, MetadataWriterPlugin, + InterfaceActionBase as InterfaceAction, + PreferencesPlugin, platform, InvalidPlugin) from calibre.customize.conversion import InputFormatPlugin, OutputFormatPlugin +from calibre.customize.zipplugin import loader from calibre.customize.profiles import InputProfile, OutputProfile from calibre.customize.builtins import plugins as builtin_plugins -from calibre.constants import numeric_version as version, iswindows, isosx from calibre.devices.interface import DevicePlugin from calibre.ebooks.metadata import MetaInformation from calibre.ebooks.metadata.covers import CoverDownload @@ -22,14 +21,6 @@ from calibre.utils.config import make_config_dir, Config, ConfigProxy, \ from calibre.ebooks.epub.fix import ePubFixer from calibre.ebooks.metadata.sources.base import Source -platform = 'linux' -if iswindows: - platform = 'windows' -elif isosx: - platform = 'osx' - -from zipfile import ZipFile - def _config(): c = Config('customize') c.add_opt('plugins', default={}, help=_('Installed plugins')) @@ -42,11 +33,6 @@ def _config(): config = _config() -class InvalidPlugin(ValueError): - pass - -class PluginNotFound(ValueError): - pass def find_plugin(name): for plugin in _initialized_plugins: @@ -60,38 +46,7 @@ def load_plugin(path_to_zip_file): # {{{ :return: A :class:`Plugin` instance. ''' - #print 'Loading plugin from', path_to_zip_file - if not os.access(path_to_zip_file, os.R_OK): - raise PluginNotFound - with closing(ZipFile(path_to_zip_file)) as zf: - for name in zf.namelist(): - if name.lower().endswith('plugin.py'): - locals = {} - raw = zf.read(name) - lines, encoding = raw.splitlines(), 'utf-8' - cr = re.compile(r'coding[:=]\s*([-\w.]+)') - raw = [] - for l in lines[:2]: - match = cr.search(l) - if match is not None: - encoding = match.group(1) - else: - raw.append(l) - raw += lines[2:] - raw = '\n'.join(raw) - raw = raw.decode(encoding) - raw = re.sub('\r\n', '\n', raw) - exec raw in locals - for x in locals.values(): - if isinstance(x, type) and issubclass(x, Plugin) and \ - x.name != 'Trivial Plugin': - if x.minimum_calibre_version > version or \ - platform not in x.supported_platforms: - continue - - return x - - raise InvalidPlugin(_('No valid plugin found in ')+path_to_zip_file) + return loader.load(path_to_zip_file) # }}} diff --git a/src/calibre/customize/zipplugin.py b/src/calibre/customize/zipplugin.py new file mode 100644 index 0000000000..194f444207 --- /dev/null +++ b/src/calibre/customize/zipplugin.py @@ -0,0 +1,154 @@ +#!/usr/bin/env python +# vim:fileencoding=UTF-8:ts=4:sw=4:sta:et:sts=4:ai +from __future__ import (unicode_literals, division, absolute_import, + print_function) + +__license__ = 'GPL v3' +__copyright__ = '2011, Kovid Goyal ' +__docformat__ = 'restructuredtext en' + +import os, zipfile, posixpath, importlib, threading, re +from collections import OrderedDict + +from calibre.customize import (Plugin, numeric_version, platform, + InvalidPlugin, PluginNotFound) + +# PEP 302 based plugin loading mechanism, works around the bug in zipimport in +# python 2.x that prevents importing from zip files in locations whose paths +# have non ASCII characters + + +class PluginLoader(object): + + ''' + The restrictions that a zip file must obey to be a valid calibre plugin + are: + + * The .py file that defines the main plugin class must have a name + that: + * Ends in plugin.py + * Is a valid python identifier (contains only English alphabets, + underscores and numbers and starts with an alphabet). This + applies to the file name minus the .py extension, obviously. + * Try to make this name as distinct as possible, as it will be + put into a global namespace of all plugins. + * The zip file must contain a .py file that defines the main plugin + class at the top level. That is, it must not be in a subdirectory. + The filename must follow the restrictions outlined above. + ''' + + def __init__(self): + self.loaded_plugins = {} + self._lock = threading.RLock() + self._identifier_pat = re.compile(r'[a-zA-Z][_0-9a-zA-Z]*') + + def load(self, path_to_zip_file): + if not os.access(path_to_zip_file, os.R_OK): + raise PluginNotFound('Cannot access %r'%path_to_zip_file) + + with zipfile.ZipFile(path_to_zip_file) as zf: + plugin_name = self._locate_code(zf, path_to_zip_file) + + try: + ans = None + m = importlib.import_module( + 'calibre_plugins.%s.__init__'%plugin_name) + for obj in m.__dict__.itervalues(): + if isinstance(obj, type) and issubclass(obj, Plugin) and \ + obj.name != 'Trivial Plugin': + ans = obj + break + if ans is None: + raise InvalidPlugin('No plugin class found in %r:%r'%( + path_to_zip_file, plugin_name)) + + if ans.minimum_calibre_version < numeric_version: + raise InvalidPlugin( + 'The plugin at %r needs a version of calibre >= %r' % + (path_to_zip_file, '.'.join(ans.minimum_calibre_version))) + + if platform not in ans.supported_platforms: + raise InvalidPlugin( + 'The plugin at %r cannot be used on %s' % + (path_to_zip_file, platform)) + + return ans + except: + with self._lock: + del self.loaded_plugins[plugin_name] + raise + + + def _locate_code(self, zf, path_to_zip_file): + names = [x if isinstance(x, unicode) else x.decode('utf-8') for x in + zf.namelist()] + names = [x[1:] if x[0] == '/' else x for x in names] + + plugin_name = None + for name in names: + name, ext = posixpath.splitext(name) + if name.startswith('plugin-import-name-') and ext == '.txt': + plugin_name = name.rpartition('-')[-1] + + if plugin_name is None: + c = 0 + while True: + c += 1 + plugin_name = 'dummy%d'%c + if plugin_name not in self.loaded_plugins: + break + else: + if plugin_name in self.loaded_plugins: + raise InvalidPlugin(( + 'The plugin in %r uses an import name %r that is already' + ' used by another plugin') % (path_to_zip_file, plugin_name)) + if self._identifier_pat.match(plugin_name) is None: + raise InvalidPlugin(( + 'The plugin at %r uses an invalid import name: %r' % + (path_to_zip_file, plugin_name))) + + pynames = [x for x in names if x.endswith('.py')] + + candidates = [posixpath.dirname(x) for x in pynames if + x.endswith('/__init__.py')] + candidates.sort(key=lambda x: x.count('/')) + valid_packages = set() + + for candidate in candidates: + parts = candidate.split('/') + parent = '.'.join(parts[:-1]) + if parent and parent not in valid_packages: + continue + valid_packages.add('.'.join(parts)) + + names = OrderedDict() + + for candidate in names: + parts = posixpath.splitext(candidate)[0].split('/') + package = '.'.join(parts[:-1]) + if package and package not in valid_packages: + continue + name = '.'.join(parts) + names[name] = zf.getinfo(candidate) + + # Legacy plugins + if '__init__' not in names: + for name in list(names.iterkeys()): + if '.' not in name and name.endswith('plugin'): + names['__init__'] = names[name] + break + + if '__init__' not in names: + raise InvalidPlugin(('The plugin in %r is invalid. It does not ' + 'contain a top-level __init__.py file') + % path_to_zip_file) + + with self._lock: + self.loaded_plugins[plugin_name] = (path_to_zip_file, names) + + return plugin_name + + +loader = PluginLoader() + + diff --git a/src/calibre/ebooks/txt/input.py b/src/calibre/ebooks/txt/input.py index 002f128392..99f7035800 100644 --- a/src/calibre/ebooks/txt/input.py +++ b/src/calibre/ebooks/txt/input.py @@ -165,8 +165,6 @@ class TXTInput(InputFormatPlugin): elif options.formatting_type == 'textile': log.debug('Running text through textile conversion...') html = convert_textile(txt) - # Textile input already runs smartypants - options.smarten_punctuation = False else: log.debug('Running text through basic conversion...') flow_size = getattr(options, 'flow_size', 0)