A fork of hyde, the static site generation. Some patches will be pushed upstream.
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

532 lines
17 KiB

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