/hyde/plugin.py

http://github.com/hyde/hyde · Python · 531 lines · 400 code · 37 blank · 94 comment · 37 complexity · 0a16662831c020e9a22e151bfdd29cff MD5 · raw file

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