From db2879035ead1c989dc1a523de459ed4e1f4da9b Mon Sep 17 00:00:00 2001 From: Lakshmi Vyasarajan Date: Sun, 2 Jan 2011 20:55:58 +0530 Subject: [PATCH] Added plugin lifecycle hooks and tests. Moved media into the content folder --- hyde/ext/templates/jinja.py | 6 +- hyde/fs.py | 17 +- hyde/generator.py | 97 +++++-- hyde/loader.py | 10 +- hyde/model.py | 3 +- hyde/plugin.py | 73 +++++- hyde/site.py | 1 + hyde/tests/__init__.py | 0 .../sites/test_jinja/content/crossdomain.xml | 22 +- .../{ => content}/media/css/site.css | 0 hyde/tests/test_fs.py | 2 + hyde/tests/test_model.py | 3 +- hyde/tests/test_plugin.py | 246 ++++++++++++++++++ hyde/tests/test_site.py | 3 +- 14 files changed, 432 insertions(+), 51 deletions(-) create mode 100644 hyde/tests/__init__.py rename hyde/tests/sites/test_jinja/{ => content}/media/css/site.css (100%) create mode 100644 hyde/tests/test_plugin.py diff --git a/hyde/ext/templates/jinja.py b/hyde/ext/templates/jinja.py index 4e39703..c0f5687 100644 --- a/hyde/ext/templates/jinja.py +++ b/hyde/ext/templates/jinja.py @@ -6,8 +6,7 @@ from hyde.fs import File, Folder from hyde.template import Template from jinja2 import contextfunction, Environment, FileSystemLoader, Undefined - -class LoyalUndefined(Undefined): +class SilentUndefined(Undefined): def __getattr__(self, name): return self @@ -45,12 +44,11 @@ class Jinja2Template(Template): if config: loader = FileSystemLoader([ str(config.content_root_path), - str(config.media_root_path), str(config.layout_root_path), ]) else: loader = FileSystemLoader(str(self.sitepath)) - self.env = Environment(loader=loader, undefined=LoyalUndefined) + self.env = Environment(loader=loader, undefined=SilentUndefined) self.env.globals['media_url'] = media_url self.env.globals['content_url'] = content_url diff --git a/hyde/fs.py b/hyde/fs.py index c197b90..d3cf4b0 100644 --- a/hyde/fs.py +++ b/hyde/fs.py @@ -171,12 +171,27 @@ class File(FS): (mime, encoding) = mimetypes.guess_type(self.path) return mime + @property + def is_binary(self): + """Return true if this is a binary file.""" + with open(self.path, 'rb') as fin: + CHUNKSIZE = 1024 + while 1: + chunk = fin.read(CHUNKSIZE) + if '\0' in chunk: + return True + if len(chunk) < CHUNKSIZE: + break + return False + @property def is_text(self): - return self.mimetype.split("/")[0] == "text" + """Return true if this is a text file.""" + return (not self.is_binary) @property def is_image(self): + """Return true if this is an image file.""" return self.mimetype.split("/")[0] == "image" def read_all(self, encoding='utf-8'): diff --git a/hyde/generator.py b/hyde/generator.py index 5b6d64f..e7c4c39 100644 --- a/hyde/generator.py +++ b/hyde/generator.py @@ -3,6 +3,7 @@ The generator class and related utility functions. """ from hyde.exceptions import HydeException from hyde.fs import File +from hyde.plugin import Plugin from hyde.template import Template from contextlib import contextmanager @@ -24,6 +25,30 @@ class Generator(object): self.site = site self.__context__ = dict(site=site) self.template = None + Plugin.load_all(site) + + class PluginProxy(object): + """ + A proxy class to raise events in registered plugins + """ + + def __init__(self, site): + super(PluginProxy, self).__init__() + self.site = site + + def __getattr__(self, method_name): + if hasattr(Plugin, method_name): + + def __call_plugins__(*args, **kwargs): + if self.site.plugins: + for plugin in self.site.plugins: + if hasattr(plugin, method_name): + function = getattr(plugin, method_name) + function(*args, **kwargs) + return __call_plugins__ + raise HydeException( + "Unknown plugin method [%s] called." % method_name) + self.events = PluginProxy(self.site) @contextmanager def context_for_resource(self, resource): @@ -37,7 +62,7 @@ class Generator(object): yield self.__context__ self.__context__.update(resource=None) - def initialize_template_if_needed(self): + def load_template_if_needed(self): """ Loads and configures the template environement from the site configuration if its not done already. @@ -50,7 +75,16 @@ class Generator(object): logger.info("Configuring the template environment") self.template.configure(self.site.config) - def reload_if_needed(self): + self.events.template_loaded(self.template) + + def initialize(self): + """ + Start Generation. Perform setup tasks and inform plugins. + """ + logger.info("Begin Generation") + self.events.begin_generation() + + def load_site_if_needed(self): """ Checks if the site requries a reload and loads if necessary. @@ -60,25 +94,35 @@ class Generator(object): logger.info("Reading site contents") self.site.load() + def finalize(self): + """ + Generation complete. Inform plugins and cleanup. + """ + logger.info("Generation Complete") + self.events.generation_complete() + def generate_all(self): """ Generates the entire website """ logger.info("Reading site contents") - self.initialize_template_if_needed() - self.reload_if_needed() - + self.load_template_if_needed() + self.initialize() + self.load_site_if_needed() + self.events.begin_site() logger.info("Generating site to [%s]" % self.site.config.deploy_root_path) self.__generate_node__(self.site.content) + self.events.site_complete() + self.finalize() def generate_node_at_path(self, node_path=None): """ Generates a single node. If node_path is non-existent or empty, generates the entire site. """ - self.initialize_template_if_needed() - self.reload_if_needed() + self.load_template_if_needed() + self.load_site_if_needed() node = None if node_path: node = self.site.content.node_from_path(node_path) @@ -89,12 +133,16 @@ class Generator(object): Generates the given node. If node is invalid, empty or non-existent, generates the entire website. """ - self.initialize_template_if_needed() - self.reload_if_needed() if not node: return self.generate_all() + + self.load_template_if_needed() + self.initialize() + self.load_site_if_needed() + try: self.__generate_node__(node) + self.finalize() except HydeException: self.generate_all() @@ -103,31 +151,39 @@ class Generator(object): Generates a single resource. If resource_path is non-existent or empty, generats the entire website. """ - self.initialize_template_if_needed() - self.reload_if_needed() + self.load_template_if_needed() + self.load_site_if_needed() resource = None if resource_path: resource = self.site.content.resource_from_path(resource_path) - return self.generate_resource(resource) + self.generate_resource(resource) def generate_resource(self, resource=None): """ Generates the given resource. If resource is invalid, empty or non-existent, generates the entire website. """ - self.initialize_template_if_needed() - self.reload_if_needed() if not resource: return self.generate_all() + + self.load_template_if_needed() + self.initialize() + self.load_site_if_needed() + try: self.__generate_resource__(resource) + self.finalize() except HydeException: self.generate_all() + def __generate_node__(self, node): logger.info("Generating [%s]", node) - for resource in node.walk_resources(): - self.__generate_resource__(resource) + for node in node.walk(): + self.events.begin_node(node) + for resource in node.resources: + self.__generate_resource__(resource) + self.events.node_complete(node) def __generate_resource__(self, resource): logger.info("Processing [%s]", resource) @@ -137,10 +193,15 @@ class Generator(object): self.site.content.source_folder)) target.parent.make() if resource.source_file.is_text: + text = resource.source_file.read_all() + text = self.events.begin_text_resource(resource, text) or text logger.info("Rendering [%s]", resource) - text = self.template.render(resource.source_file.read_all(), - context) + text = self.template.render(text, context) + text = self.events.text_resource_complete( + resource, text) or text target.write(text) else: logger.info("Copying binary file [%s]", resource) + self.events.begin_binary_resource(resource) resource.source_file.copy_to(target) + self.events.binary_resource_complete(resource) diff --git a/hyde/loader.py b/hyde/loader.py index 1b3dc72..522a223 100644 --- a/hyde/loader.py +++ b/hyde/loader.py @@ -5,6 +5,11 @@ import sys from hyde.exceptions import HydeException +import logging +from logging import NullHandler +logger = logging.getLogger('hyde.engine') +logger.addHandler(NullHandler()) + plugins = {} templates = {} @@ -17,6 +22,7 @@ def load_python_object(name): if module_name == '': (module_name, object_name) = (object_name, module_name) try: + logger.info('Loading module [%s]' % module_name) module = __import__(module_name) except ImportError: raise HydeException("The given module name [%s] is invalid." % @@ -32,11 +38,13 @@ def load_python_object(name): module_name) try: + logger.info('Getting object [%s] from module [%s]' % + (object_name, module_name)) return getattr(module, object_name) except AttributeError: raise HydeException("Cannot load the specified plugin [%s]. " "The given module [%s] does not contain the " - "desired object [%s]. Please fix the" + "desired object [%s]. Please fix the " "configuration or ensure that the module is " "installed properly" % (name, module_name, object_name)) diff --git a/hyde/model.py b/hyde/model.py index 4ae5657..fb664bc 100644 --- a/hyde/model.py +++ b/hyde/model.py @@ -44,7 +44,8 @@ class Config(Expando): media_root='media', layout_root='layout', media_url='/media', - site_url='/' + site_url='/', + plugins = [] ) conf = dict(**default_config) if config_dict: diff --git a/hyde/plugin.py b/hyde/plugin.py index 63daa43..9f02a63 100644 --- a/hyde/plugin.py +++ b/hyde/plugin.py @@ -2,12 +2,14 @@ """ Contains definition for a plugin protocol and other utiltities. """ - +import abc +from hyde import loader class Plugin(object): """ The plugin protocol """ + __metaclass__ = abc.ABCMeta def __init__(self, site): super(Plugin, self).__init__() @@ -19,60 +21,105 @@ class Plugin(object): """ pass - def prepare_site(self): + def begin_generation(self): """ Called when generation is about to take place. """ pass - def site_load_complete(self): + def begin_site(self): """ - Called when the site is built complete. This implies that all the + Called when the site is loaded completely. This implies that all the nodes and resources have been identified and are accessible in the site variable. """ pass - def prepare_node(self, node): + def begin_node(self, node): """ Called when a node is about to be processed for generation. + This method is called only when the entire node is generated. """ pass - def prepare_resource(self, resource, text): + def begin_text_resource(self, resource, text): """ - Called when a resource is about to be processed for generation. - The `text` parameter contains the, resource text at this point + Called when a text resource is about to be processed for generation. + The `text` parameter contains the resource text at this point in its lifecycle. It is the text that has been loaded and any plugins that are higher in the order may have tampered with it. - But the text has not been processed by the template yet. + But the text has not been processed by the template yet. Note that + the source file associated with the text resource may not be modifed + by any plugins. If this function returns a value, it is used as the text for further processing. """ return text - def process_resource(self, resource, text): + def begin_binary_resource(self, resource): + """ + Called when a binary resource is about to be processed for generation. + + Plugins are free to modify the contents of the file. + """ + pass + + def text_resource_complete(self, resource, text): """ Called when a resource has been processed by the template. - The `text` parameter contains the, resource text at this point + The `text` parameter contains the resource text at this point in its lifecycle. It is the text that has been processed by the template and any plugins that are higher in the order may have - tampered with it. + tampered with it. Note that the source file associated with the + text resource may not be modifed by any plugins. If this function returns a value, it is used as the text for further processing. """ return text + def binary_resource_complete(self, resource): + """ + Called when a binary resource has already been processed. + + Plugins are free to modify the contents of the file. + """ + pass + def node_complete(self, node): """ Called when all the resources in the node have been processed. + This method is called only when the entire node is generated. + """ + pass + + def site_complete(self): + """ + Called when the entire site has been processed. This method is called + only when the entire site is generated. """ pass def site_complete(self): """ - Called when the entire site has been processed. + Called when the generation process is complete. This method is called + only when the entire site is generated. + """ + pass + + def generation_complete(self): + """ + Called when generation is completed. """ pass + + @staticmethod + def load_all(site): + """ + Loads plugins based on the configuration. Assigns the plugins to + 'site.plugins' + """ + + site.plugins = [loader.load_python_object(name)(site) + for name in site.config.plugins] diff --git a/hyde/site.py b/hyde/site.py index 703d60b..badcae6 100644 --- a/hyde/site.py +++ b/hyde/site.py @@ -275,6 +275,7 @@ class Site(object): self.sitepath = Folder(str(sitepath)) self.config = config if config else Config(self.sitepath) self.content = RootNode(self.config.content_root_path, self) + self.plugins = [] def load(self): """ diff --git a/hyde/tests/__init__.py b/hyde/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hyde/tests/sites/test_jinja/content/crossdomain.xml b/hyde/tests/sites/test_jinja/content/crossdomain.xml index 0d42929..5b938f1 100644 --- a/hyde/tests/sites/test_jinja/content/crossdomain.xml +++ b/hyde/tests/sites/test_jinja/content/crossdomain.xml @@ -1,24 +1,24 @@ - - + + - - - - + + + + diff --git a/hyde/tests/sites/test_jinja/media/css/site.css b/hyde/tests/sites/test_jinja/content/media/css/site.css similarity index 100% rename from hyde/tests/sites/test_jinja/media/css/site.css rename to hyde/tests/sites/test_jinja/content/media/css/site.css diff --git a/hyde/tests/test_fs.py b/hyde/tests/test_fs.py index c1acad5..aa85424 100644 --- a/hyde/tests/test_fs.py +++ b/hyde/tests/test_fs.py @@ -109,6 +109,7 @@ HELPERS = File(JINJA2.child('helpers.html')) INDEX = File(JINJA2.child('index.html')) LAYOUT = File(JINJA2.child('layout.html')) LOGO = File(TEMPLATE_ROOT.child('../../../resources/hyde-logo.png')) +XML = File(TEMPLATE_ROOT.child('../sites/test_jinja/content/crossdomain.xml')) def test_ancestors(): depth = 0 @@ -156,6 +157,7 @@ def test_mimetype(): def test_is_text(): assert HELPERS.is_text assert not LOGO.is_text + assert XML.is_text def test_is_image(): assert not HELPERS.is_image diff --git a/hyde/tests/test_model.py b/hyde/tests/test_model.py index 7e91121..1854c09 100644 --- a/hyde/tests/test_model.py +++ b/hyde/tests/test_model.py @@ -62,7 +62,8 @@ class TestConfig(object): assert getattr(c, name) == root assert hasattr(c, path) assert getattr(c, path) == TEST_SITE_ROOT.child_folder(root) - + assert hasattr(c, 'plugins') + assert len(c.plugins) == 0 assert c.deploy_root_path == TEST_SITE_ROOT.child_folder('deploy') def test_conf1(self): diff --git a/hyde/tests/test_plugin.py b/hyde/tests/test_plugin.py new file mode 100644 index 0000000..7cd61ed --- /dev/null +++ b/hyde/tests/test_plugin.py @@ -0,0 +1,246 @@ +# -*- coding: utf-8 -*- +""" +Use nose +`$ pip install nose` +`$ nosetests` +""" + +from hyde.exceptions import HydeException +from hyde.fs import File, Folder +from hyde.generator import Generator +from hyde.plugin import Plugin +from hyde.site import Site + +from mock import patch +from nose.tools import raises, nottest, with_setup + + +TEST_SITE = File(__file__).parent.child_folder('_test') + +class PluginLoaderStub(Plugin): + pass + + +class TestPlugins(object): + + @classmethod + def setup_class(cls): + TEST_SITE.make() + TEST_SITE.parent.child_folder('sites/test_jinja').copy_contents_to(TEST_SITE) + folders = [] + text_files = [] + binary_files = [] + + with TEST_SITE.child_folder('content').walker as walker: + @walker.folder_visitor + def visit_folder(folder): + folders.append(folder.path) + + @walker.file_visitor + def visit_file(afile): + if not afile.is_text: + binary_files.append(afile.path) + else: + text_files.append(afile.path) + + cls.content_nodes = sorted(folders) + cls.content_text_resources = sorted(text_files) + cls.content_binary_resources = sorted(binary_files) + + + @classmethod + def teardown_class(cls): + TEST_SITE.delete() + + def setUp(self): + self.site = Site(TEST_SITE) + self.site.config.plugins = ['hyde.tests.test_plugin.PluginLoaderStub'] + + def test_can_load_plugin_modules(self): + assert not len(self.site.plugins) + Plugin.load_all(self.site) + + assert len(self.site.plugins) == 1 + assert self.site.plugins[0].__class__.__name__ == 'PluginLoaderStub' + + + def test_generator_loads_plugins(self): + gen = Generator(self.site) + assert len(self.site.plugins) == 1 + + def test_generator_template_registered_called(self): + with patch.object(PluginLoaderStub, 'template_loaded') as template_loaded_stub: + gen = Generator(self.site) + gen.generate_all() + assert template_loaded_stub.call_count == 1 + + def test_generator_template_begin_generation_called(self): + with patch.object(PluginLoaderStub, 'begin_generation') as begin_generation_stub: + gen = Generator(self.site) + gen.generate_all() + assert begin_generation_stub.call_count == 1 + + def test_generator_template_begin_generation_called_for_single_resource(self): + with patch.object(PluginLoaderStub, 'begin_generation') as begin_generation_stub: + gen = Generator(self.site) + path = self.site.content.source_folder.child('about.html') + gen.generate_resource_at_path(path) + + assert begin_generation_stub.call_count == 1 + + def test_generator_template_begin_generation_called_for_single_node(self): + with patch.object(PluginLoaderStub, 'begin_generation') as begin_generation_stub: + gen = Generator(self.site) + path = self.site.content.source_folder + gen.generate_node_at_path(path) + assert begin_generation_stub.call_count == 1 + + + def test_generator_template_generation_complete_called(self): + with patch.object(PluginLoaderStub, 'generation_complete') as generation_complete_stub: + gen = Generator(self.site) + gen.generate_all() + assert generation_complete_stub.call_count == 1 + + def test_generator_template_generation_complete_called_for_single_resource(self): + with patch.object(PluginLoaderStub, 'generation_complete') as generation_complete_stub: + gen = Generator(self.site) + path = self.site.content.source_folder.child('about.html') + gen.generate_resource_at_path(path) + + assert generation_complete_stub.call_count == 1 + + def test_generator_template_generation_complete_called_for_single_node(self): + with patch.object(PluginLoaderStub, 'generation_complete') as generation_complete_stub: + gen = Generator(self.site) + path = self.site.content.source_folder + gen.generate_node_at_path(path) + assert generation_complete_stub.call_count == 1 + + def test_generator_template_begin_site_called(self): + with patch.object(PluginLoaderStub, 'begin_site') as begin_site_stub: + gen = Generator(self.site) + gen.generate_all() + assert begin_site_stub.call_count == 1 + + def test_generator_template_begin_site_not_called_for_single_resource(self): + with patch.object(PluginLoaderStub, 'begin_site') as begin_site_stub: + gen = Generator(self.site) + path = self.site.content.source_folder.child('about.html') + gen.generate_resource_at_path(path) + assert begin_site_stub.call_count == 0 + + def test_generator_template_begin_site_not_called_for_single_node(self): + with patch.object(PluginLoaderStub, 'begin_site') as begin_site_stub: + gen = Generator(self.site) + path = self.site.content.source_folder + gen.generate_node_at_path(path) + + assert begin_site_stub.call_count == 0 + + def test_generator_template_site_complete_called(self): + with patch.object(PluginLoaderStub, 'site_complete') as site_complete_stub: + gen = Generator(self.site) + gen.generate_all() + assert site_complete_stub.call_count == 1 + + + def test_generator_template_site_complete_not_called_for_single_resource(self): + + with patch.object(PluginLoaderStub, 'site_complete') as site_complete_stub: + gen = Generator(self.site) + path = self.site.content.source_folder.child('about.html') + gen.generate_resource_at_path(path) + + assert site_complete_stub.call_count == 0 + + def test_generator_template_site_complete_not_called_for_single_node(self): + + with patch.object(PluginLoaderStub, 'site_complete') as site_complete_stub: + gen = Generator(self.site) + path = self.site.content.source_folder + gen.generate_node_at_path(path) + + assert site_complete_stub.call_count == 0 + + def test_generator_template_begin_node_called(self): + + with patch.object(PluginLoaderStub, 'begin_node') as begin_node_stub: + gen = Generator(self.site) + gen.generate_all() + + + assert begin_node_stub.call_count == len(self.content_nodes) + called_with_nodes = sorted([arg[0][0].path for arg in begin_node_stub.call_args_list]) + assert called_with_nodes == self.content_nodes + + def test_generator_template_begin_node_not_called_for_single_resource(self): + + with patch.object(PluginLoaderStub, 'begin_node') as begin_node_stub: + gen = Generator(self.site) + gen.generate_resource_at_path(self.site.content.source_folder.child('about.html')) + assert begin_node_stub.call_count == 0 + + + def test_generator_template_node_complete_called(self): + + with patch.object(PluginLoaderStub, 'node_complete') as node_complete_stub: + gen = Generator(self.site) + gen.generate_all() + + + assert node_complete_stub.call_count == len(self.content_nodes) + called_with_nodes = sorted([arg[0][0].path for arg in node_complete_stub.call_args_list]) + assert called_with_nodes == self.content_nodes + + def test_generator_template_node_complete_not_called_for_single_resource(self): + + with patch.object(PluginLoaderStub, 'node_complete') as node_complete_stub: + gen = Generator(self.site) + gen.generate_resource_at_path(self.site.content.source_folder.child('about.html')) + assert node_complete_stub.call_count == 0 + + def test_generator_template_begin_text_resource_called(self): + + with patch.object(PluginLoaderStub, 'begin_text_resource') as begin_text_resource_stub: + gen = Generator(self.site) + gen.generate_all() + + + called_with_resources = sorted([arg[0][0].path for arg in begin_text_resource_stub.call_args_list]) + assert begin_text_resource_stub.call_count == len(self.content_text_resources) + assert called_with_resources == self.content_text_resources + + def test_generator_template_begin_text_resource_called_for_single_resource(self): + + with patch.object(PluginLoaderStub, 'begin_text_resource') as begin_text_resource_stub: + gen = Generator(self.site) + path = self.site.content.source_folder.child('about.html') + gen.generate_resource_at_path(path) + + called_with_resources = sorted([arg[0][0].path for arg in begin_text_resource_stub.call_args_list]) + assert begin_text_resource_stub.call_count == 1 + assert called_with_resources[0] == path + + def test_generator_template_begin_binary_resource_called(self): + + with patch.object(PluginLoaderStub, 'begin_binary_resource') as begin_binary_resource_stub: + gen = Generator(self.site) + gen.generate_all() + + + called_with_resources = sorted([arg[0][0].path for arg in begin_binary_resource_stub.call_args_list]) + assert begin_binary_resource_stub.call_count == len(self.content_binary_resources) + assert called_with_resources == self.content_binary_resources + + def test_generator_template_begin_binary_resource_called_for_single_resource(self): + + with patch.object(PluginLoaderStub, 'begin_binary_resource') as begin_binary_resource_stub: + gen = Generator(self.site) + path = self.site.content.source_folder.child('favicon.ico') + gen.generate_resource_at_path(path) + + + called_with_resources = sorted([arg[0][0].path for arg in begin_binary_resource_stub.call_args_list]) + assert begin_binary_resource_stub.call_count == 1 + assert called_with_resources[0] == path diff --git a/hyde/tests/test_site.py b/hyde/tests/test_site.py index 9581955..52f0e84 100644 --- a/hyde/tests/test_site.py +++ b/hyde/tests/test_site.py @@ -75,7 +75,8 @@ def test_walk_resources(): "merry-christmas.html", "crossdomain.xml", "favicon.ico", - "robots.txt" + "robots.txt", + "site.css" ] pages.sort() expected.sort()