PageRenderTime 40ms CodeModel.GetById 13ms app.highlight 22ms RepoModel.GetById 1ms app.codeStats 0ms

/hyde/plugin.py

http://github.com/hyde/hyde
Python | 531 lines | 494 code | 17 blank | 20 comment | 12 complexity | 0a16662831c020e9a22e151bfdd29cff MD5 | raw file
  1# -*- coding: utf-8 -*-
  2"""
  3Contains definition for a plugin protocol and other utiltities.
  4"""
  5from hyde._compat import str
  6from hyde.exceptions import HydeException
  7from hyde.util import first_match, discover_executable
  8from hyde.model import Expando
  9
 10import abc
 11from functools import partial
 12import fnmatch
 13import os
 14import re
 15import subprocess
 16import sys
 17
 18from commando.util import getLoggerWithNullHandler, load_python_object
 19from fswrap import File
 20
 21from hyde._compat import with_metaclass
 22
 23logger = getLoggerWithNullHandler('hyde.engine')
 24
 25# Plugins have been reorganized. Map old plugin paths to new.
 26PLUGINS_OLD_AND_NEW = {
 27    "hyde.ext.plugins.less.LessCSSPlugin":
 28        "hyde.ext.plugins.css.LessCSSPlugin",
 29    "hyde.ext.plugins.stylus.StylusPlugin":
 30        "hyde.ext.plugins.css.StylusPlugin",
 31    "hyde.ext.plugins.jpegoptim.JPEGOptimPlugin":
 32        "hyde.ext.plugins.images.JPEGOptimPlugin",
 33    "hyde.ext.plugins.optipng.OptiPNGPlugin":
 34        "hyde.ext.plugins.images.OptiPNGPlugin",
 35    "hyde.ext.plugins.jpegtran.JPEGTranPlugin":
 36        "hyde.ext.plugins.images.JPEGTranPlugin",
 37    "hyde.ext.plugins.uglify.UglifyPlugin":
 38        "hyde.ext.plugins.js.UglifyPlugin",
 39    "hyde.ext.plugins.requirejs.RequireJSPlugin":
 40        "hyde.ext.plugins.js.RequireJSPlugin",
 41    "hyde.ext.plugins.coffee.CoffeePlugin":
 42        "hyde.ext.plugins.js.CoffeePlugin",
 43    "hyde.ext.plugins.sorter.SorterPlugin":
 44        "hyde.ext.plugins.meta.SorterPlugin",
 45    "hyde.ext.plugins.grouper.GrouperPlugin":
 46        "hyde.ext.plugins.meta.GrouperPlugin",
 47    "hyde.ext.plugins.tagger.TaggerPlugin":
 48        "hyde.ext.plugins.meta.TaggerPlugin",
 49    "hyde.ext.plugins.auto_extend.AutoExtendPlugin":
 50        "hyde.ext.plugins.meta.AutoExtendPlugin",
 51    "hyde.ext.plugins.folders.FlattenerPlugin":
 52        "hyde.ext.plugins.structure.FlattenerPlugin",
 53    "hyde.ext.plugins.combine.CombinePlugin":
 54        "hyde.ext.plugins.structure.CombinePlugin",
 55    "hyde.ext.plugins.paginator.PaginatorPlugin":
 56        "hyde.ext.plugins.structure.PaginatorPlugin",
 57    "hyde.ext.plugins.blockdown.BlockdownPlugin":
 58        "hyde.ext.plugins.text.BlockdownPlugin",
 59    "hyde.ext.plugins.markings.MarkingsPlugin":
 60        "hyde.ext.plugins.text.MarkingsPlugin",
 61    "hyde.ext.plugins.markings.ReferencePlugin":
 62        "hyde.ext.plugins.text.ReferencePlugin",
 63    "hyde.ext.plugins.syntext.SyntextPlugin":
 64        "hyde.ext.plugins.text.SyntextPlugin",
 65    "hyde.ext.plugins.textlinks.TextlinksPlugin":
 66        "hyde.ext.plugins.text.TextlinksPlugin",
 67    "hyde.ext.plugins.git.GitDatesPlugin":
 68        "hyde.ext.plugins.vcs.GitDatesPlugin"
 69}
 70
 71
 72class PluginProxy(object):
 73
 74    """
 75    A proxy class to raise events in registered  plugins
 76    """
 77
 78    def __init__(self, site):
 79        super(PluginProxy, self).__init__()
 80        self.site = site
 81
 82    def __getattr__(self, method_name):
 83        if hasattr(Plugin, method_name):
 84            def __call_plugins__(*args):
 85                res = None
 86                if self.site.plugins:
 87                    for plugin in self.site.plugins:
 88                        if hasattr(plugin, method_name):
 89                            checker = getattr(
 90                                plugin, 'should_call__' + method_name)
 91                            if checker(*args):
 92                                function = getattr(plugin, method_name)
 93                                try:
 94                                    res = function(*args)
 95                                except:
 96                                    HydeException.reraise(
 97                                        'Error occured when calling %s' %
 98                                        plugin.plugin_name, sys.exc_info())
 99                                targs = list(args)
100                                if len(targs):
101                                    last = targs.pop()
102                                    res = res if res else last
103                                    targs.append(res)
104                                    args = tuple(targs)
105                return res
106
107            return __call_plugins__
108        raise HydeException(
109            "Unknown plugin method [%s] called." % method_name)
110
111
112class Plugin(with_metaclass(abc.ABCMeta)):
113
114    """
115    The plugin protocol
116    """
117
118    def __init__(self, site):
119        super(Plugin, self).__init__()
120        self.site = site
121        self.logger = getLoggerWithNullHandler(
122            'hyde.engine.%s' % self.__class__.__name__)
123        self.template = None
124
125    def template_loaded(self, template):
126        """
127        Called when the template for the site has been identified.
128
129        Handles the template loaded event to keep
130        a reference to the template object.
131        """
132        self.template = template
133
134    def __getattribute__(self, name):
135        """
136        Syntactic sugar for template methods
137        """
138        result = None
139        if name.startswith('t_') and self.template:
140            attr = name[2:]
141            if hasattr(self.template, attr):
142                result = self.template[attr]
143            elif attr.endswith('_close_tag'):
144                tag = attr.replace('_close_tag', '')
145                result = partial(self.template.get_close_tag, tag)
146            elif attr.endswith('_open_tag'):
147                tag = attr.replace('_open_tag', '')
148                result = partial(self.template.get_open_tag, tag)
149        elif name.startswith('should_call__'):
150            (_, _, method) = name.rpartition('__')
151            if (method in ('begin_text_resource', 'text_resource_complete',
152                           'begin_binary_resource',
153                           'binary_resource_complete')):
154                result = self._file_filter
155            elif (method in ('begin_node', 'node_complete')):
156                result = self._dir_filter
157            else:
158                def always_true(*args, **kwargs):
159                    return True
160                result = always_true
161
162        return result if result else super(Plugin, self).__getattribute__(name)
163
164    @property
165    def settings(self):
166        """
167        The settings for this plugin the site config.
168        """
169
170        opts = Expando({})
171        try:
172            opts = getattr(self.site.config, self.plugin_name)
173        except AttributeError:
174            pass
175        return opts
176
177    @property
178    def plugin_name(self):
179        """
180        The name of the plugin. Makes an intelligent guess.
181
182        This is used to lookup the settings for the plugin.
183        """
184
185        return self.__class__.__name__.replace('Plugin', '').lower()
186
187    def begin_generation(self):
188        """
189        Called when generation is about to take place.
190        """
191        pass
192
193    def begin_site(self):
194        """
195        Called when the site is loaded completely. This implies that all the
196        nodes and resources have been identified and are accessible in the
197        site variable.
198        """
199        pass
200
201    def begin_node(self, node):
202        """
203        Called when a node is about to be processed for generation.
204        This method is called only when the entire node is generated.
205        """
206        pass
207
208    def _file_filter(self, resource, *args, **kwargs):
209        """
210        Returns True if the resource path matches the filter property in
211        plugin settings.
212        """
213
214        if not self._dir_filter(resource.node, *args, **kwargs):
215            return False
216
217        try:
218            filters = self.settings.include_file_pattern
219            if not isinstance(filters, list):
220                filters = [filters]
221        except AttributeError:
222            filters = None
223        result = any(fnmatch.fnmatch(resource.path, f)
224                     for f in filters) if filters else True
225        return result
226
227    def _dir_filter(self, node, *args, **kwargs):
228        """
229        Returns True if the node path is a descendant of the
230        include_paths property in plugin settings.
231        """
232        try:
233            node_filters = self.settings.include_paths
234            if not isinstance(node_filters, list):
235                node_filters = [node_filters]
236            node_filters = [self.site.content.node_from_relative_path(f)
237                            for f in node_filters]
238        except AttributeError:
239            node_filters = None
240        result = any(node.source == f.source or
241                     node.source.is_descendant_of(f.source)
242                     for f in node_filters if f) \
243            if node_filters else True
244        return result
245
246    def begin_text_resource(self, resource, text):
247        """
248        Called when a text resource is about to be processed for generation.
249        The `text` parameter contains the resource text at this point
250        in its lifecycle. It is the text that has been loaded and any
251        plugins that are higher in the order may have tampered with it.
252        But the text has not been processed by the template yet. Note that
253        the source file associated with the text resource may not be modifed
254        by any plugins.
255
256        If this function returns a value, it is used as the text for further
257        processing.
258        """
259        return text
260
261    def begin_binary_resource(self, resource):
262        """
263        Called when a binary resource is about to be processed for generation.
264
265        Plugins are free to modify the contents of the file.
266        """
267        pass
268
269    def text_resource_complete(self, resource, text):
270        """
271        Called when a resource has been processed by the template.
272        The `text` parameter contains the resource text at this point
273        in its lifecycle. It is the text that has been processed by the
274        template and any plugins that are higher in the order may have
275        tampered with it. Note that the source file associated with the
276        text resource may not be modifed by any plugins.
277
278        If this function returns a value, it is used as the text for further
279        processing.
280        """
281        return text
282
283    def binary_resource_complete(self, resource):
284        """
285        Called when a binary resource has already been processed.
286
287        Plugins are free to modify the contents of the file.
288        """
289        pass
290
291    def node_complete(self, node):
292        """
293        Called when all the resources in the node have been processed.
294        This method is called only when the entire node is generated.
295        """
296        pass
297
298    def site_complete(self):
299        """
300        Called when the entire site has been processed. This method is called
301        only when the entire site is generated.
302        """
303        pass
304
305    def generation_complete(self):
306        """
307        Called when generation is completed.
308        """
309        pass
310
311    @staticmethod
312    def load_all(site):
313        """
314        Loads plugins based on the configuration. Assigns the plugins to
315        'site.plugins'
316        """
317        def load_plugin(name):
318            plugin_name = PLUGINS_OLD_AND_NEW.get(name, name)
319            return load_python_object(plugin_name)(site)
320
321        site.plugins = [load_plugin(name)
322                        for name in site.config.plugins]
323
324    @staticmethod
325    def get_proxy(site):
326        """
327        Returns a new instance of the Plugin proxy.
328        """
329        return PluginProxy(site)
330
331
332class CLTransformer(Plugin):
333
334    """
335    Handy class for plugins that simply call a command line app to
336    transform resources.
337    """
338    @property
339    def defaults(self):
340        """
341        Default command line options. Can be overridden
342        by specifying them in config.
343        """
344
345        return {}
346
347    @property
348    def executable_name(self):
349        """
350        The executable name for the plugin. This can be overridden in the
351        config. If a configuration option is not provided, this is used
352        to guess the complete path of the executable.
353        """
354        return self.plugin_name
355
356    @property
357    def executable_not_found_message(self):
358        """
359        Message to be displayed if the command line application
360        is not found.
361        """
362
363        return ("%(name)s executable path not configured properly. "
364                "This plugin expects `%(name)s.app` to point "
365                "to the full path of the `%(exec)s` executable." %
366                {
367                    "name": self.plugin_name, "exec": self.executable_name
368                })
369
370    @property
371    def app(self):
372        """
373        Gets the application path from the site configuration.
374
375        If the path is not configured, attempts to guess the path
376        from the sytem path environment variable.
377        """
378
379        try:
380            app_path = getattr(self.settings, 'app')
381        except AttributeError:
382            app_path = self.executable_name
383
384        # Honour the PATH environment variable.
385        if app_path is not None and not os.path.isabs(app_path):
386            app_path = discover_executable(app_path, self.site.sitepath)
387
388        if app_path is None:
389            raise HydeException(self.executable_not_found_message)
390        app = File(app_path)
391
392        if not app.exists:
393            raise HydeException(self.executable_not_found_message)
394
395        return app
396
397    def option_prefix(self, option):
398        """
399        Return the prefix for the given option.
400
401        Defaults to --.
402        """
403        return "--"
404
405    def process_args(self, supported):
406        """
407        Given a list of supported arguments, consutructs an argument
408        list that could be passed on to the call_app function.
409        """
410        args = {}
411        args.update(self.defaults)
412        try:
413            args.update(self.settings.args.to_dict())
414        except AttributeError:
415            pass
416
417        params = []
418        for option in supported:
419            if isinstance(option, tuple):
420                (descriptive, short) = option
421            else:
422                descriptive = short = option
423
424            options = [descriptive.rstrip("="), short.rstrip("=")]
425            match = first_match(lambda arg: arg in options, args)
426            if match:
427                val = args[match]
428                param = "%s%s" % (self.option_prefix(descriptive),
429                                  descriptive)
430                if descriptive.endswith("="):
431                    param += val
432                    val = None
433                params.append(param)
434                if val:
435                    params.append(val)
436        return params
437
438    def call_app(self, args):
439        """
440        Calls the application with the given command line parameters.
441        """
442        try:
443            self.logger.debug(
444                "Calling executable [%s] with arguments %s" %
445                (args[0], str(args[1:])))
446            return subprocess.check_output(args)
447        except subprocess.CalledProcessError as error:
448            self.logger.error(error.output)
449            raise
450
451
452class TextyPlugin(with_metaclass(abc.ABCMeta, Plugin)):
453
454    """
455    Base class for text preprocessing plugins.
456
457    Plugins that desire to provide syntactic sugar for
458    commonly used hyde functions for various templates
459    can inherit from this class.
460    """
461
462    def __init__(self, site):
463        super(TextyPlugin, self).__init__(site)
464        self.open_pattern = self.default_open_pattern
465        self.close_pattern = self.default_close_pattern
466        self.template = None
467        config = getattr(site.config, self.plugin_name, None)
468
469        if config and hasattr(config, 'open_pattern'):
470            self.open_pattern = config.open_pattern
471
472        if self.close_pattern and config and hasattr(config, 'close_pattern'):
473            self.close_pattern = config.close_pattern
474
475    @property
476    def plugin_name(self):
477        """
478        The name of the plugin. Makes an intelligent guess.
479        """
480        return self.__class__.__name__.replace('Plugin', '').lower()
481
482    @abc.abstractproperty
483    def tag_name(self):
484        """
485        The tag that this plugin tries add syntactic sugar for.
486        """
487        return self.plugin_name
488
489    @abc.abstractproperty
490    def default_open_pattern(self):
491        """
492        The default pattern for opening the tag.
493        """
494        return None
495
496    @abc.abstractproperty
497    def default_close_pattern(self):
498        """
499        The default pattern for closing the tag.
500        """
501        return None
502
503    def get_params(self, match, start=True):
504        """
505        Default implementation for getting template args.
506        """
507        return match.groups(1)[0] if match.lastindex else ''
508
509    @abc.abstractmethod
510    def text_to_tag(self, match, start=True):
511        """
512        Replaces the matched text with tag statement
513        given by the template.
514        """
515        params = self.get_params(match, start)
516        return (self.template.get_open_tag(self.tag_name, params)
517                if start
518                else self.template.get_close_tag(self.tag_name, params))
519
520    def begin_text_resource(self, resource, text):
521        """
522        Replace a text base pattern with a template statement.
523        """
524        text_open = re.compile(self.open_pattern, re.UNICODE | re.MULTILINE)
525        text = text_open.sub(self.text_to_tag, text)
526        if self.close_pattern:
527            text_close = re.compile(
528                self.close_pattern, re.UNICODE | re.MULTILINE)
529            text = text_close.sub(
530                partial(self.text_to_tag, start=False), text)
531        return text