Zine

open source content publishing system


source: zine/pluginsystem.py @ 1310:0a87b1a9bbbd

Revision 1310:0a87b1a9bbbd, 19.4 KB checked in by Georg Brandl <georg@…>, 2 years ago (diff)

Spellchecking docstring.

Line 
1# -*- coding: utf-8 -*-
2"""
3    zine.pluginsystem
4    ~~~~~~~~~~~~~~~~~
5
6    This module implements the plugin system.
7
8
9    Plugin Distribution
10    -------------------
11
12    The best way to distribute plugins are `.plugin` files.  Those files are
13    simple zip files that are uncompressed when installed from the plugin
14    admin panel.  You can easily create .plugin files yourself.  Just finish
15    the plugin and use the `scripts/bundle-plugin` script or do it
16    programmatically::
17
18        app.plugins['<name of the plugin>'].dump('/target/filename.plugin')
19
20    This will save the plugin as `.plugin` package. The preferred filename
21    for templates is `<FILESYSTEM_NAME>-<VERSION>.plugin`.  So if
22    you want to dump all the plugins you have into plugin files you can use
23    this snippet::
24
25        for plugin in app.plugins.itervalues():
26            plugin.dump('%s-%s.plugin' % (
27                plugin.filesystem_name,
28                plugin.version
29            ))
30
31    It's only possible to create packages of plugins that are bound to an
32    application so just create a development instance for plugin development.
33
34
35    Plugin Metadata
36    ---------------
37
38    To identify a plugin metadata is used. Zine requires a file
39    named `metadata.txt` to load some information about the plugin.
40
41    Zine currently supports the following metadata information:
42
43    :Name:
44        The full name of the plugin.
45    :Plugin URL:
46        The URL of the plugin (e.g download location).
47    :Description:
48        The full description of the plugin.
49    :Author:
50        The name of the author of the plugin.
51        Use the this field in the form of ``Name <author@webpage.xy>``
52        where `Name` is the full name of the author.
53    :Author URL:
54        The website of the plugin author.
55    :Contributors:
56        Add a list of all contributors separated by a comma.
57        Use this field in the form of ``Name1 <n1@w1.xy>, Name2
58        <n2@w2.xy>`` where `Name` is the full name of the author
59        and the email is optional.
60    :Version:
61        The version of the deployed plugin.
62    :Preview:
63        *For themes only*
64        A little preview of the theme deployed by the plugin.
65    :Depends:
66        A list of plugins the plugin depends on.  All plugin names will
67        be split at a comma and also named exactly as the depended plugin.
68        All plugins in this list will be activated if found but if one
69        is missing, the admin will be informed about that and the plugin
70        won't be activated.
71
72    Each key can be suffixed with "[LANG_CODE]" for internationalization::
73
74        Title: Example Plugin
75        Title[de]: Beispielplugin
76
77
78    :copyright: (c) 2010 by the Zine Team, see AUTHORS for more details.
79    :license: BSD, see LICENSE for more details.
80"""
81import __builtin__
82import re
83import sys
84import inspect
85from os import path, listdir, walk, makedirs
86from types import ModuleType
87from shutil import rmtree
88from time import localtime, time
89
90from urllib import quote
91from werkzeug import cached_property, escape
92
93from zine.application import get_application
94from zine.utils import log
95from zine.utils.mail import split_email, is_valid_email, check
96from zine.utils.exceptions import UserException, summarize_exception
97from zine.i18n import ZineTranslations as Translations, lazy_gettext, _
98from zine.environment import BUILTIN_PLUGIN_FOLDER
99
100
101_py_import = __builtin__.__import__
102_i18n_key_re = re.compile(r'^(.*?)\[([^\]]+)\]$')
103
104#: a dict of all managed applications by iid.
105#: every application in this dict has a plugin space this module
106#: controls.  This is only used internally
107_managed_applications = {}
108
109PACKAGE_VERSION = 1
110
111
112def get_object_name(obj):
113    """Return a human readable name for the object."""
114    if inspect.isclass(obj) or inspect.isfunction(obj):
115        cls = obj
116    else:
117        cls = obj.__class__
118    if cls.__module__.startswith('zine.plugins.'):
119        prefix = cls.__module__.split('.', 2)[-1]
120    elif cls.__module__.startswith('zine.'):
121        prefix = cls.__module__
122    else:
123        prefix = 'external.' + cls.__module__
124    return prefix + '.' + cls.__name__
125
126
127def find_plugins(app):
128    """Return an iterator over all plugins available."""
129    enabled_plugins = set()
130    found_plugins = set()
131    for plugin in app.cfg['plugins']:
132        plugin = plugin.strip()
133        if plugin:
134            enabled_plugins.add(plugin)
135
136    for folder in app.plugin_searchpath:
137        if not path.isdir(folder):
138            continue
139        for filename in listdir(folder):
140            full_name = path.join(folder, filename)
141            if path.isdir(full_name) and \
142               path.isfile(path.join(full_name, 'metadata.txt')) and \
143               filename not in found_plugins:
144                found_plugins.add(filename)
145                yield Plugin(app, str(filename), path.abspath(full_name),
146                             filename in enabled_plugins)
147
148
149def install_package(app, package):
150    """Install a plugin from a package to the instance plugin folder."""
151    from zipfile import ZipFile, error as BadZipFile
152    import py_compile
153    try:
154        f = ZipFile(package)
155    except (IOError, BadZipFile):
156        raise InstallationError('invalid')
157
158    # get the package version
159    try:
160        package_version = int(f.read('ZINE_PACKAGE'))
161        plugin_name = f.read('ZINE_PLUGIN')
162    except (KeyError, ValueError):
163        raise InstallationError('invalid')
164
165    # check if the package version is handleable
166    if package_version > PACKAGE_VERSION:
167        raise InstallationError('version')
168
169    # check if there is already a plugin with the same name
170    plugin_path = path.join(app.instance_folder, 'plugins', plugin_name)
171    if path.exists(plugin_path):
172        raise InstallationError('exists')
173
174    # make sure that we have a folder
175    try:
176        makedirs(plugin_path)
177    except (IOError, OSError):
178        pass
179
180    # now read all the files and write them to the folder
181    for filename in f.namelist():
182        if not filename.startswith('pdata/'):
183            continue
184        dst_filename = path.join(plugin_path, *filename[6:].split('/'))
185        try:
186            makedirs(path.dirname(dst_filename))
187        except (IOError, OSError):
188            pass
189        try:
190            dst = file(dst_filename, 'wb')
191        except IOError:
192            raise InstallationError('ioerror')
193        try:
194            dst.write(f.read(filename))
195        finally:
196            dst.close()
197
198        if filename.endswith('.py'):
199            py_compile.compile(dst_filename)
200
201    plugin = Plugin(app, plugin_name, plugin_path, False)
202    app.plugins[plugin_name] = plugin
203    app.cfg.touch()
204    return plugin
205
206
207def get_package_metadata(package):
208    """Get the metadata of a plugin in a package. Pass it a filepointer or
209    filename. Raises a `ValueError` if the package is not valid.
210    """
211    from zipfile import ZipFile, error as BadZipFile
212    try:
213        f = ZipFile(package)
214    except (IOError, BadZipFile):
215        raise ValueError('not a valid package')
216
217    # get the package version and name
218    try:
219        package_version = int(f.read('ZINE_PACKAGE'))
220        plugin_name = f.read('ZINE_PLUGIN')
221    except (KeyError, ValueError):
222        raise ValueError('not a valid package')
223    if package_version > PACKAGE_VERSION:
224        raise ValueError('incompatible package version')
225
226    try:
227        metadata = parse_metadata(f.read('pdata/metadata.txt'))
228    except KeyError:
229        metadata = {}
230    metadata['uid'] = plugin_name
231    return metadata
232
233
234def parse_metadata(string_or_fp):
235    """Parse the metadata and return it as metadata object."""
236    result = {}
237    translations = {}
238    if isinstance(string_or_fp, basestring):
239        fileiter = iter(string_or_fp.splitlines(True))
240    else:
241        fileiter = iter(string_or_fp.readline, '')
242    fileiter = (line.decode('utf-8') for line in fileiter)
243    for line in fileiter:
244        line = line.strip()
245        if not line or line.startswith('#'):
246            continue
247        if not ':' in line:
248            key = line.strip()
249            value = ''
250        else:
251            key, value = line.split(':', 1)
252        while value.endswith('\\'):
253            try:
254                value = value[:-1] + fileiter.next().rstrip('\n')
255            except StopIteration:
256                pass
257        key = '_'.join(key.lower().split()).encode('ascii', 'ignore')
258        value = value.lstrip()
259        match = _i18n_key_re.match(key)
260        if match is not None:
261            key, lang = match.groups()
262            translations.setdefault(lang, {})[key] = value
263        else:
264            result[key] = value
265    return MetaData(result, translations)
266
267
268class MetaData(object):
269    """Holds metadata.  This object has a dict like interface to the metadata
270    from the file and will return the values for the current language by
271    default.  It's however possible to get an "untranslated" version of the
272    metadata by calling the `untranslated` method.
273    """
274
275    def __init__(self, values, i18n_values=None):
276        self._values = values
277        self._i18n_values = i18n_values or {}
278
279    def untranslated(self):
280        """Return a metadata object without translations."""
281        return MetaData(self._values)
282
283    def __getitem__(self, name):
284        locale = str(get_application().locale)
285        if name in self._i18n_values.get(locale, ()):
286            return self._i18n_values[locale][name]
287        if name in self._values:
288            return self._values[name]
289        raise KeyError(name)
290
291    def get(self, name, default=None):
292        """Return a key or the default value if no value exists."""
293        try:
294            return self[name]
295        except KeyError:
296            return default
297
298    def __contains__(self, name):
299        try:
300            self[name]
301        except KeyError:
302            return False
303        return True
304
305    def _dict_method(name):
306        def proxy(self):
307            return getattr(self.as_dict(), name)()
308        proxy.__name__ = name
309        proxy.__doc__ = getattr(dict, name).__doc__
310        return proxy
311
312    __iter__ = iterkeys = _dict_method('iterkeys')
313    itervalues = _dict_method('itervalues')
314    iteritems = _dict_method('iteritems')
315    keys = _dict_method('keys')
316    values = _dict_method('values')
317    items = _dict_method('items')
318    del _dict_method
319
320    def as_dict(self):
321        result = self._values.copy()
322        result.update(self._i18n_values.get(str(get_application().locale), {}))
323        return result
324
325
326class InstallationError(UserException):
327    """Raised during plugin installation."""
328
329    MESSAGES = {
330        'invalid':  lazy_gettext('Could not install the plugin because the '
331                                 'uploaded file is not a valid plugin file.'),
332        'version':  lazy_gettext('The plugin uploaded has a newer package '
333                                 'version than this Zine installation '
334                                 'can handle.'),
335        'exists':   lazy_gettext('A plugin with the same UID is already '
336                                 'installed. Aborted.'),
337        'ioerror':  lazy_gettext('Could not install the package because the '
338                                 'installer wasn\'t able to write the package '
339                                 'information. Wrong permissions?')
340    }
341
342    def __init__(self, code):
343        UserException.__init__(self, self.MESSAGES[code])
344        self.code = code
345
346
347class SetupError(UserException):
348    """Raised by plugins if they want to stop their setup.  If a plugin raises
349    a `SetupError` during the init, it will be disabled automatically.
350    """
351
352
353def make_setup_error(exc_info=None):
354    """Create a new SetupError for the last exception and log it."""
355    if exc_info is None:
356        exc_info = sys.exc_info()
357
358    # log the exception
359    log.exception(_(u'Plugin setup error'), 'pluginsystem', exc_info)
360    exc_type, exc_value, tb = exc_info
361
362    # if the exception is already a SetupError we only
363    # have to return it unchanged.
364    if isinstance(exc_value, SetupError):
365        return exc_value
366
367    # otherwise create an error message for it and return a new
368    # exception.
369    error, (filename, line) = summarize_exception(exc_info)
370    return SetupError(_(u'Exception happend on setup: '
371                        u'%(error)s (%(file)s, line %(line)d)') % {
372        'error':    escape(error),
373        'file':     filename,
374        'line':     line
375    })
376
377
378class Plugin(object):
379    """Wraps a plugin module."""
380
381    def __init__(self, app, name, path_, active):
382        self.app = app
383        self.name = name
384        self.path = path_
385        self.active = active
386        self.instance_plugin = path.commonprefix([
387            path.realpath(path_), path.realpath(app.plugin_folder)]) == \
388            app.plugin_folder
389        self.setup_error = None
390
391    def remove(self):
392        """Remove the plugin from the instance folder."""
393        if not self.instance_plugin:
394            raise ValueError('cannot remove non instance-plugins')
395        if self.active:
396            raise ValueError('cannot remove active plugin')
397        rmtree(self.path)
398        del self.app.plugins[self.name]
399
400    def dump(self, fp):
401        """Dump the plugin as package into the filepointer or file."""
402        from zipfile import ZipFile, ZipInfo
403        f = ZipFile(fp, 'w')
404
405        # write all files into a "pdata/" folder
406        offset = len(self.path) + 1
407        for dirpath, dirnames, filenames in walk(self.path):
408            # don't recurse into hidden dirs
409            for i in range(len(dirnames)-1, -1, -1):
410                if dirnames[i].startswith('.'):
411                    del dirnames[i]
412            for filename in filenames:
413                if filename.endswith('.pyc') or \
414                   filename.endswith('.pyo'):
415                    continue
416                f.write(path.join(dirpath, filename),
417                        path.join('pdata', dirpath[offset:], filename))
418
419        # add the package information files
420        for name, data in [('ZINE_PLUGIN', self.name),
421                           ('ZINE_PACKAGE', PACKAGE_VERSION)]:
422            zinfo = ZipInfo(name, localtime(time()))
423            zinfo.compress_type = f.compression
424            zinfo.external_attr = (33188 & 0xFFFF) << 16L
425            f.writestr(zinfo, str(data))
426
427        f.close()
428
429    @cached_property
430    def metadata(self):
431        try:
432            f = file(path.join(self.path, 'metadata.txt'))
433        except IOError:
434            return {}
435        try:
436            return parse_metadata(f)
437        finally:
438            f.close()
439
440    @cached_property
441    def translations(self):
442        """The translations for this application."""
443        locale_path = path.join(self.path, 'i18n')
444        return Translations.load(locale_path, self.app.cfg['language'])
445
446    @cached_property
447    def is_documented(self):
448        """This property is True if the plugin has documentation."""
449        for lang in self.app.cfg['language'], 'en':
450            if path.isfile(path.join(self.path, 'docs', lang, 'index.page')):
451                return True
452        return False
453
454    @cached_property
455    def is_bundled(self):
456        """This property is True if the plugin is bundled with Zine."""
457        return path.commonprefix([
458            path.realpath(self.path), path.realpath(BUILTIN_PLUGIN_FOLDER)]) == \
459            BUILTIN_PLUGIN_FOLDER
460
461    @cached_property
462    def module(self):
463        """The module of the plugin. The first access imports it."""
464        try:
465            # we directly import from the zine module space
466            return __import__('zine.plugins.%s' % self.name, None, None,
467                              ['setup'])
468        except:
469            if not self.app.cfg['plugin_guard']:
470                raise
471            self.setup_error = make_setup_error()
472
473    @property
474    def display_name(self):
475        """The full name from the metadata."""
476        return self.metadata.get('name', self.name)
477
478    @property
479    def filesystem_name(self):
480        """The human readable package name for the filesystem."""
481        string = self.metadata.untranslated().get('name', self.name)
482        return ''.join(string.split())
483
484    @property
485    def html_display_name(self):
486        """The display name as HTML link."""
487        link = self.plugin_url
488        if link:
489            return u'<a href="%s">%s</a>' % (
490                escape(link),
491                escape(self.display_name)
492            )
493        return escape(self.display_name)
494
495    @property
496    def plugin_url(self):
497        """Return the URL of the plugin."""
498        return self.metadata.get('plugin_url')
499
500    @property
501    def description(self):
502        """Return the description of the plugin."""
503        return self.metadata.get('description', u'')
504
505    @property
506    def has_author(self):
507        """Does the plugin has an author at all?"""
508        return 'author' in self.metadata
509
510    @property
511    def author_info(self):
512        """The author, mail and author URL of the plugin."""
513        return split_email(self.metadata.get('author', u'Nobody')) + \
514               (self.metadata.get('author_url'),)
515
516    @property
517    def contributors(self):
518        """The Contributors of the plugin."""
519        data = self.metadata.get('contributors', '')
520        if not data:
521            return []
522        return [split_email(c.strip()) for c in
523        self.metadata.get('contributors', '').split(',')]
524
525    @property
526    def html_contributors_info(self):
527        result = []
528        for contributor in self.contributors:
529            name, contact = contributor
530            if not contact:
531                result.append(escape(name))
532            else:
533                result.append('<a href="%s">%s</a>' % (
534                    escape(check(is_valid_email, contact) and
535                           'mailto:' + contact or contact),
536                    escape(name)
537                ))
538        return u', '.join(result)
539
540    @property
541    def html_author_info(self):
542        """Return the author info as html link."""
543        name, email, url = self.author_info
544        if not url:
545            if not email:
546                return escape(name)
547            url = 'mailto:%s' % quote(email)
548        return u'<a href="%s">%s</a>' % (
549            escape(url),
550            escape(name)
551        )
552
553    @property
554    def author(self):
555        """Return the author of the plugin."""
556        x = self.author_info
557        return x[0] or x[1]
558
559    @property
560    def author_email(self):
561        """Return the author email address of the plugin."""
562        return self.author_info[1]
563
564    @property
565    def author_url(self):
566        """Return the URL of the author of the plugin."""
567        return self.author_info[2]
568
569    @property
570    def version(self):
571        """The version of the plugin."""
572        return self.metadata.get('version')
573
574    @property
575    def depends(self):
576        """A list of depenencies for this plugin.
577
578        Plugins listed here won't be loaded automaticly.
579        """
580        depends = self.metadata.get('depends', '').strip()
581        return filter(None, [x.strip() for x in depends.split(',')])
582
583    def setup(self):
584        """Setup the plugin."""
585        try:
586            self.module.setup(self.app, self)
587        except:
588            if self.setup_error is None:
589                self.setup_error = make_setup_error()
590            if not self.app.cfg['plugin_guard']:
591                raise
592
593    def __repr__(self):
594        return '<%s %r>' % (
595            self.__class__.__name__,
596            self.name
597        )
598
599
600def set_plugin_searchpath(searchpath):
601    """Set the plugin searchpath for the plugin pseudo package."""
602    _plugins.__path__ = searchpath
603
604
605# the application imports this on setup and modifies it
606sys.modules['zine.plugins'] = _plugins = ModuleType('zine.plugins')
Note: See TracBrowser for help on using the repository browser.