diff options
author | Chris Jerdonek <chris.jerdonek@gmail.com> | 2012-05-03 15:53:54 -0700 |
---|---|---|
committer | Chris Jerdonek <chris.jerdonek@gmail.com> | 2012-05-03 15:53:54 -0700 |
commit | 8bc8baf31ae4869df1ca0ef3b9c8837d0a075d9d (patch) | |
tree | 364c2111e301c455569a3a59a12d89a10fbe1ce8 /pystache | |
parent | cc262abf19cd90e34390d5ddb5db30d6f04620fa (diff) | |
parent | 3c839348727aab1da9ee48e532124083df3a364d (diff) | |
download | pystache-8bc8baf31ae4869df1ca0ef3b9c8837d0a075d9d.tar.gz |
Merge branch 'development' into 'master': staging v0.5.2-rc
Diffstat (limited to 'pystache')
-rw-r--r-- | pystache/__init__.py | 2 | ||||
-rw-r--r-- | pystache/commands/render.py | 3 | ||||
-rw-r--r-- | pystache/commands/test.py | 2 | ||||
-rw-r--r-- | pystache/common.py | 12 | ||||
-rw-r--r-- | pystache/context.py | 172 | ||||
-rw-r--r-- | pystache/locator.py | 6 | ||||
-rw-r--r-- | pystache/parsed.py | 5 | ||||
-rw-r--r-- | pystache/parser.py | 74 | ||||
-rw-r--r-- | pystache/renderengine.py | 64 | ||||
-rw-r--r-- | pystache/renderer.py | 12 | ||||
-rw-r--r-- | pystache/tests/common.py | 39 | ||||
-rw-r--r-- | pystache/tests/examples/simple.py | 2 | ||||
-rw-r--r-- | pystache/tests/main.py | 4 | ||||
-rw-r--r-- | pystache/tests/spectesting.py | 41 | ||||
-rw-r--r-- | pystache/tests/test_context.py | 147 | ||||
-rw-r--r-- | pystache/tests/test_locator.py | 8 | ||||
-rw-r--r-- | pystache/tests/test_parser.py | 26 | ||||
-rw-r--r-- | pystache/tests/test_renderengine.py | 152 | ||||
-rw-r--r-- | pystache/tests/test_renderer.py | 29 | ||||
-rw-r--r-- | pystache/tests/test_specloader.py | 6 |
20 files changed, 627 insertions, 179 deletions
diff --git a/pystache/__init__.py b/pystache/__init__.py index 5f5035d..d1fbdb1 100644 --- a/pystache/__init__.py +++ b/pystache/__init__.py @@ -10,4 +10,4 @@ from pystache.init import render, Renderer, TemplateSpec __all__ = ['render', 'Renderer', 'TemplateSpec'] -__version__ = '0.5.1' # Also change in setup.py. +__version__ = '0.5.2-rc' # Also change in setup.py. diff --git a/pystache/commands/render.py b/pystache/commands/render.py index 23b19f8..1a9c309 100644 --- a/pystache/commands/render.py +++ b/pystache/commands/render.py @@ -35,6 +35,7 @@ import sys # # ValueError: Attempted relative import in non-package # +from pystache.common import TemplateNotFoundError from pystache.renderer import Renderer @@ -78,7 +79,7 @@ def main(sys_argv=sys.argv): try: template = renderer.load_template(template) - except IOError: + except TemplateNotFoundError: pass try: diff --git a/pystache/commands/test.py b/pystache/commands/test.py index aec1ff6..0872453 100644 --- a/pystache/commands/test.py +++ b/pystache/commands/test.py @@ -7,7 +7,7 @@ This module provides a command to test pystache (unit tests, doctests, etc). import sys -from pystache.tests.main import run_tests +from pystache.tests.main import main as run_tests def main(sys_argv=sys.argv): diff --git a/pystache/common.py b/pystache/common.py index 00f8a77..c1fd7a1 100644 --- a/pystache/common.py +++ b/pystache/common.py @@ -1,7 +1,7 @@ # coding: utf-8 """ -Exposes common functions. +Exposes functionality needed throughout the project. """ @@ -24,3 +24,13 @@ def read(path): return f.read() finally: f.close() + + +class PystacheError(Exception): + """Base class for Pystache exceptions.""" + pass + + +class TemplateNotFoundError(PystacheError): + """An exception raised when a template is not found.""" + pass diff --git a/pystache/context.py b/pystache/context.py index a8f3964..8a95059 100644 --- a/pystache/context.py +++ b/pystache/context.py @@ -1,7 +1,16 @@ # coding: utf-8 """ -Defines a Context class to represent mustache(5)'s notion of context. +Exposes a ContextStack class. + +The Mustache spec makes a special distinction between two types of context +stack elements: hashes and objects. For the purposes of interpreting the +spec, we define these categories mutually exclusively as follows: + + (1) Hash: an item whose type is a subclass of dict. + + (2) Object: an item that is neither a hash nor an instance of a + built-in type. """ @@ -22,28 +31,23 @@ class NotFound(object): _NOT_FOUND = NotFound() -# TODO: share code with template.check_callable(). -def _is_callable(obj): - return hasattr(obj, '__call__') - - -def _get_value(item, key): +def _get_value(context, key): """ - Retrieve a key's value from an item. + Retrieve a key's value from a context item. Returns _NOT_FOUND if the key does not exist. - The Context.get() docstring documents this function's intended behavior. + The ContextStack.get() docstring documents this function's intended behavior. """ - if isinstance(item, dict): + if isinstance(context, dict): # Then we consider the argument a "hash" for the purposes of the spec. # # We do a membership test to avoid using exceptions for flow control # (e.g. catching KeyError). - if key in item: - return item[key] - elif type(item).__module__ != _BUILTIN_MODULE: + if key in context: + return context[key] + elif type(context).__module__ != _BUILTIN_MODULE: # Then we consider the argument an "object" for the purposes of # the spec. # @@ -51,16 +55,18 @@ def _get_value(item, key): # types like integers and strings as objects (cf. issue #81). # Instances of user-defined classes on the other hand, for example, # are considered objects by the test above. - if hasattr(item, key): - attr = getattr(item, key) - if _is_callable(attr): + if hasattr(context, key): + attr = getattr(context, key) + # TODO: consider using EAFP here instead. + # http://docs.python.org/glossary.html#term-eafp + if callable(attr): return attr() return attr return _NOT_FOUND -class Context(object): +class ContextStack(object): """ Provides dictionary-like access to a stack of zero or more items. @@ -75,7 +81,7 @@ class Context(object): (last in, first out). Caution: this class does not currently support recursive nesting in - that items in the stack cannot themselves be Context instances. + that items in the stack cannot themselves be ContextStack instances. See the docstrings of the methods of this class for more details. @@ -92,7 +98,7 @@ class Context(object): stack in order so that, in particular, items at the end of the argument list are queried first when querying the stack. - Caution: items should not themselves be Context instances, as + Caution: items should not themselves be ContextStack instances, as recursive nesting does not behave as one might expect. """ @@ -104,9 +110,9 @@ class Context(object): For example-- - >>> context = Context({'alpha': 'abc'}, {'numeric': 123}) + >>> context = ContextStack({'alpha': 'abc'}, {'numeric': 123}) >>> repr(context) - "Context({'alpha': 'abc'}, {'numeric': 123})" + "ContextStack({'alpha': 'abc'}, {'numeric': 123})" """ return "%s%s" % (self.__class__.__name__, tuple(self._stack)) @@ -114,18 +120,18 @@ class Context(object): @staticmethod def create(*context, **kwargs): """ - Build a Context instance from a sequence of context-like items. + Build a ContextStack instance from a sequence of context-like items. - This factory-style method is more general than the Context class's + This factory-style method is more general than the ContextStack class's constructor in that, unlike the constructor, the argument list - can itself contain Context instances. + can itself contain ContextStack instances. Here is an example illustrating various aspects of this method: >>> obj1 = {'animal': 'cat', 'vegetable': 'carrot', 'mineral': 'copper'} - >>> obj2 = Context({'vegetable': 'spinach', 'mineral': 'silver'}) + >>> obj2 = ContextStack({'vegetable': 'spinach', 'mineral': 'silver'}) >>> - >>> context = Context.create(obj1, None, obj2, mineral='gold') + >>> context = ContextStack.create(obj1, None, obj2, mineral='gold') >>> >>> context.get('animal') 'cat' @@ -136,7 +142,7 @@ class Context(object): Arguments: - *context: zero or more dictionaries, Context instances, or objects + *context: zero or more dictionaries, ContextStack instances, or objects with which to populate the initial context stack. None arguments will be skipped. Items in the *context list are added to the stack in order so that later items in the argument @@ -152,12 +158,12 @@ class Context(object): """ items = context - context = Context() + context = ContextStack() for item in items: if item is None: continue - if isinstance(item, Context): + if isinstance(item, ContextStack): context._stack.extend(item._stack) else: context.push(item) @@ -167,9 +173,22 @@ class Context(object): return context - def get(self, key, default=None): + # TODO: add more unit tests for this. + # TODO: update the docstring for dotted names. + def get(self, name, default=u''): """ - Query the stack for the given key, and return the resulting value. + Resolve a dotted name against the current context stack. + + This function follows the rules outlined in the section of the + spec regarding tag interpolation. This function returns the value + as is and does not coerce the return value to a string. + + Arguments: + + name: a dotted or non-dotted name. + + default: the value to return if name resolution fails at any point. + Defaults to the empty string per the Mustache spec. This method queries items in the stack in order from last-added objects to first (last in, first out). The value returned is @@ -177,30 +196,21 @@ class Context(object): If the key is not found in any item in the stack, then the default value is returned. The default value defaults to None. - When speaking about returning values from a context, the Mustache - spec distinguishes between two types of context stack elements: - hashes and objects. - In accordance with the spec, this method queries items in the - stack for a key in the following way. For the purposes of querying, - each item is classified into one of the following three mutually - exclusive categories: a hash, an object, or neither: - - (1) Hash: if the item's type is a subclass of dict, then the item - is considered a hash (in the terminology of the spec), and - the key's value is the dictionary value of the key. If the - dictionary doesn't contain the key, the key is not found. - - (2) Object: if the item isn't a hash and isn't an instance of a - built-in type, then the item is considered an object (again - using the language of the spec). In this case, the method - looks for an attribute with the same name as the key. If an - attribute with that name exists, the value of the attribute is - returned. If the attribute is callable, however (i.e. if the - attribute is a method), then the attribute is called with no - arguments and instead that value returned. If there is no - attribute with the same name as the key, then the key is - considered not found. + stack for a key differently depending on whether the item is a + hash, object, or neither (as defined in the module docstring): + + (1) Hash: if the item is a hash, then the key's value is the + dictionary value of the key. If the dictionary doesn't contain + the key, then the key is considered not found. + + (2) Object: if the item is an an object, then the method looks for + an attribute with the same name as the key. If an attribute + with that name exists, the value of the attribute is returned. + If the attribute is callable, however (i.e. if the attribute + is a method), then the attribute is called with no arguments + and that value is returned. If there is no attribute with + the same name as the key, then the key is considered not found. (3) Neither: if the item is neither a hash nor an object, then the key is considered not found. @@ -226,23 +236,59 @@ class Context(object): >>> >>> dct['greet'] is obj.greet True - >>> Context(dct).get('greet') #doctest: +ELLIPSIS + >>> ContextStack(dct).get('greet') #doctest: +ELLIPSIS <function greet at 0x...> - >>> Context(obj).get('greet') + >>> ContextStack(obj).get('greet') 'Hi Bob!' TODO: explain the rationale for this difference in treatment. """ - for obj in reversed(self._stack): - val = _get_value(obj, key) - if val is _NOT_FOUND: + if name == '.': + # TODO: should we add a test case for an empty context stack? + return self.top() + + parts = name.split('.') + + result = self._get_simple(parts[0]) + + for part in parts[1:]: + # TODO: consider using EAFP here instead. + # http://docs.python.org/glossary.html#term-eafp + if result is _NOT_FOUND: + break + # The full context stack is not used to resolve the remaining parts. + # From the spec-- + # + # 5) If any name parts were retained in step 1, each should be + # resolved against a context stack containing only the result + # from the former resolution. If any part fails resolution, the + # result should be considered falsey, and should interpolate as + # the empty string. + # + # TODO: make sure we have a test case for the above point. + result = _get_value(result, part) + + if result is _NOT_FOUND: + return default + + return result + + def _get_simple(self, name): + """ + Query the stack for a non-dotted name. + + """ + result = _NOT_FOUND + + for item in reversed(self._stack): + result = _get_value(item, name) + if result is _NOT_FOUND: continue # Otherwise, the key was found. - return val - # Otherwise, no item in the stack contained the key. + break - return default + return result def push(self, item): """ @@ -270,4 +316,4 @@ class Context(object): Return a copy of this instance. """ - return Context(*self._stack) + return ContextStack(*self._stack) diff --git a/pystache/locator.py b/pystache/locator.py index a1f06db..2189cf2 100644 --- a/pystache/locator.py +++ b/pystache/locator.py @@ -9,6 +9,7 @@ import os import re import sys +from pystache.common import TemplateNotFoundError from pystache import defaults @@ -117,9 +118,8 @@ class Locator(object): path = self._find_path(search_dirs, file_name) if path is None: - # TODO: we should probably raise an exception of our own type. - raise IOError('Template file %s not found in directories: %s' % - (repr(file_name), repr(search_dirs))) + raise TemplateNotFoundError('File %s not found in dirs: %s' % + (repr(file_name), repr(search_dirs))) return path diff --git a/pystache/parsed.py b/pystache/parsed.py index 5418ec1..a37565b 100644 --- a/pystache/parsed.py +++ b/pystache/parsed.py @@ -17,7 +17,7 @@ class ParsedTemplate(object): parse_tree: a list, each element of which is either-- (1) a unicode string, or - (2) a "rendering" callable that accepts a Context instance + (2) a "rendering" callable that accepts a ContextStack instance and returns a unicode string. The possible rendering callables are the return values of the @@ -32,6 +32,9 @@ class ParsedTemplate(object): """ self._parse_tree = parse_tree + def __repr__(self): + return "[%s]" % (", ".join([repr(part) for part in self._parse_tree])) + def render(self, context): """ Returns: a string of type unicode. diff --git a/pystache/parser.py b/pystache/parser.py index ccb6827..4e05f3b 100644 --- a/pystache/parser.py +++ b/pystache/parser.py @@ -9,15 +9,22 @@ This module is only meant for internal use by the renderengine module. import re +from pystache.common import TemplateNotFoundError from pystache.parsed import ParsedTemplate -DEFAULT_DELIMITERS = ('{{', '}}') -END_OF_LINE_CHARACTERS = ['\r', '\n'] -NON_BLANK_RE = re.compile(r'^(.)', re.M) +DEFAULT_DELIMITERS = (u'{{', u'}}') +END_OF_LINE_CHARACTERS = [u'\r', u'\n'] +NON_BLANK_RE = re.compile(ur'^(.)', re.M) -def _compile_template_re(delimiters): +def _compile_template_re(delimiters=None): + """ + Return a regular expresssion object (re.RegexObject) instance. + + """ + if delimiters is None: + delimiters = DEFAULT_DELIMITERS # The possible tag type characters following the opening tag, # excluding "=" and "{". @@ -74,19 +81,25 @@ class Parser(object): self._delimiters = delimiters self.compile_template_re() - def parse(self, template, index=0, section_key=None): + def parse(self, template, start_index=0, section_key=None): """ - Parse a template string into a ParsedTemplate instance. + Parse a template string starting at some index. This method uses the current tag delimiter. Arguments: - template: a template string of type unicode. + template: a unicode string that is the template to parse. + + index: the index at which to start parsing. + + Returns: + + a ParsedTemplate instance. """ parse_tree = [] - start_index = index + index = start_index while True: match = self._template_re.search(template, index) @@ -133,7 +146,7 @@ class Parser(object): if tag_key != section_key: raise ParsingError("Section end tag mismatch: %s != %s" % (tag_key, section_key)) - return ParsedTemplate(parse_tree), template[start_index:match_index], end_index + return ParsedTemplate(parse_tree), match_index, end_index index = self._handle_tag_type(template, parse_tree, tag_type, tag_key, leading_whitespace, end_index) @@ -142,10 +155,33 @@ class Parser(object): return ParsedTemplate(parse_tree) - def _parse_section(self, template, index_start, section_key): - parsed_template, template, index_end = self.parse(template=template, index=index_start, section_key=section_key) + def _parse_section(self, template, start_index, section_key): + """ + Parse the contents of a template section. + + Arguments: + + template: a unicode template string. + + start_index: the string index at which the section contents begin. + + section_key: the tag key of the section. + + Returns: a 3-tuple: + + parsed_section: the section contents parsed as a ParsedTemplate + instance. + + content_end_index: the string index after the section contents. + + end_index: the string index after the closing section tag (and + including any trailing newlines). + + """ + parsed_section, content_end_index, end_index = \ + self.parse(template=template, start_index=start_index, section_key=section_key) - return parsed_template, template, index_end + return parsed_section, template[start_index:content_end_index], end_index def _handle_tag_type(self, template, parse_tree, tag_type, tag_key, leading_whitespace, end_index): @@ -170,20 +206,24 @@ class Parser(object): elif tag_type == '#': - parsed_section, template, end_index = self._parse_section(template, end_index, tag_key) - func = engine._make_get_section(tag_key, parsed_section, template, self._delimiters) + parsed_section, section_contents, end_index = self._parse_section(template, end_index, tag_key) + func = engine._make_get_section(tag_key, parsed_section, section_contents, self._delimiters) elif tag_type == '^': - parsed_section, template, end_index = self._parse_section(template, end_index, tag_key) + parsed_section, section_contents, end_index = self._parse_section(template, end_index, tag_key) func = engine._make_get_inverse(tag_key, parsed_section) elif tag_type == '>': - template = engine.load_partial(tag_key) + try: + # TODO: make engine.load() and test it separately. + template = engine.load_partial(tag_key) + except TemplateNotFoundError: + template = u'' # Indent before rendering. - template = re.sub(NON_BLANK_RE, leading_whitespace + r'\1', template) + template = re.sub(NON_BLANK_RE, leading_whitespace + ur'\1', template) func = engine._make_get_partial(template) diff --git a/pystache/renderengine.py b/pystache/renderengine.py index d9c822c..e1d57e4 100644 --- a/pystache/renderengine.py +++ b/pystache/renderengine.py @@ -35,7 +35,8 @@ class RenderEngine(object): load_partial: the function to call when loading a partial. The function should accept a string template name and return a - template string of type unicode (not a subclass). + template string of type unicode (not a subclass). If the + template is not found, it should raise a TemplateNotFoundError. literal: the function used to convert unescaped variable tag values to unicode, e.g. the value corresponding to a tag @@ -63,6 +64,7 @@ class RenderEngine(object): self.literal = literal self.load_partial = load_partial + # TODO: rename context to stack throughout this module. def _get_string_value(self, context, tag_name): """ Get a value from the given context as a basestring instance. @@ -70,15 +72,6 @@ class RenderEngine(object): """ val = context.get(tag_name) - # We use "==" rather than "is" to compare integers, as using "is" - # relies on an implementation detail of CPython. The test about - # rendering zeroes failed while using PyPy when using "is". - # See issue #34: https://github.com/defunkt/pystache/issues/34 - if not val and val != 0: - if tag_name != '.': - return '' - val = context.top() - if callable(val): # According to the spec: # @@ -132,6 +125,7 @@ class RenderEngine(object): Returns: a string of type unicode. """ + # TODO: the parsing should be done before calling this function. return self._render(template, context) return get_partial @@ -142,7 +136,10 @@ class RenderEngine(object): Returns a string with type unicode. """ + # TODO: is there a bug because we are not using the same + # logic as in _get_string_value()? data = context.get(name) + # Per the spec, lambdas in inverted sections are considered truthy. if data: return u'' return parsed_template.render(context) @@ -161,16 +158,19 @@ class RenderEngine(object): template = template_ parsed_template = parsed_template_ data = context.get(name) + + # From the spec: + # + # If the data is not of a list type, it is coerced into a list + # as follows: if the data is truthy (e.g. `!!data == true`), + # use a single-element list containing the data, otherwise use + # an empty list. + # if not data: data = [] - elif callable(data): - # TODO: should we check the arity? - template = data(template) - parsed_template = self._parse(template, delimiters=delims) - data = [data] else: - # The cleanest, least brittle way of determining whether - # something supports iteration is by trying to call iter() on it: + # The least brittle way to determine whether something + # supports iteration is by trying to call iter() on it: # # http://docs.python.org/library/functions.html#iter # @@ -184,14 +184,34 @@ class RenderEngine(object): # Then the value does not support iteration. data = [data] else: - # We treat the value as a list (but do not treat strings - # and dicts as lists). if isinstance(data, (basestring, dict)): + # Do not treat strings and dicts (which are iterable) as lists. data = [data] - # Otherwise, leave it alone. + # Otherwise, treat the value as a list. parts = [] for element in data: + if callable(element): + # Lambdas special case section rendering and bypass pushing + # the data value onto the context stack. From the spec-- + # + # When used as the data value for a Section tag, the + # lambda MUST be treatable as an arity 1 function, and + # invoked as such (passing a String containing the + # unprocessed section contents). The returned value + # MUST be rendered against the current delimiters, then + # interpolated in place of the section. + # + # Also see-- + # + # https://github.com/defunkt/pystache/issues/113 + # + # TODO: should we check the arity? + new_template = element(template) + parsed_template = self._parse(new_template, delimiters=delims) + parts.append(parsed_template.render(context)) + continue + context.push(element) parts.append(parsed_template.render(context)) context.pop() @@ -221,7 +241,7 @@ class RenderEngine(object): Arguments: template: a template string of type unicode. - context: a Context instance. + context: a ContextStack instance. """ # We keep this type-check as an added check because this method is @@ -244,7 +264,7 @@ class RenderEngine(object): template: a template string of type unicode (but not a proper subclass of unicode). - context: a Context instance. + context: a ContextStack instance. """ # Be strict but not too strict. In other words, accept str instead diff --git a/pystache/renderer.py b/pystache/renderer.py index d55c72f..a3d4c57 100644 --- a/pystache/renderer.py +++ b/pystache/renderer.py @@ -8,7 +8,8 @@ This module provides a Renderer class to render templates. import sys from pystache import defaults -from pystache.context import Context +from pystache.common import TemplateNotFoundError +from pystache.context import ContextStack from pystache.loader import Loader from pystache.renderengine import RenderEngine from pystache.specloader import SpecLoader @@ -239,9 +240,8 @@ class Renderer(object): template = partials.get(name) if template is None: - # TODO: make a TemplateNotFoundException type that provides - # the original partials as an attribute. - raise Exception("Partial not found with name: %s" % repr(name)) + raise TemplateNotFoundError("Name %s not found in partials: %s" % + (repr(name), type(partials))) # RenderEngine requires that the return value be unicode. return self._to_unicode_hard(template) @@ -277,7 +277,7 @@ class Renderer(object): # RenderEngine.render() requires that the template string be unicode. template = self._to_unicode_hard(template) - context = Context.create(*context, **kwargs) + context = ContextStack.create(*context, **kwargs) self._context = context engine = self._make_render_engine() @@ -338,7 +338,7 @@ class Renderer(object): uses the passed object as the first element of the context stack when rendering. - *context: zero or more dictionaries, Context instances, or objects + *context: zero or more dictionaries, ContextStack instances, or objects with which to populate the initial context stack. None arguments are skipped. Items in the *context list are added to the context stack in order so that later items in the argument diff --git a/pystache/tests/common.py b/pystache/tests/common.py index a99e709..24b24dc 100644 --- a/pystache/tests/common.py +++ b/pystache/tests/common.py @@ -168,6 +168,20 @@ class AssertIsMixin: self.assertTrue(first is second, msg="%s is not %s" % (repr(first), repr(second))) +class AssertExceptionMixin: + + """A unittest.TestCase mixin adding assertException().""" + + # unittest.assertRaisesRegexp() is not available until Python 2.7: + # http://docs.python.org/library/unittest.html#unittest.TestCase.assertRaisesRegexp + def assertException(self, exception_type, msg, callable, *args, **kwds): + try: + callable(*args, **kwds) + raise Exception("Expected exception: %s: %s" % (exception_type, repr(msg))) + except exception_type, err: + self.assertEqual(str(err), msg) + + class SetupDefaults(object): """ @@ -191,3 +205,28 @@ class SetupDefaults(object): defaults.FILE_ENCODING = self.original_file_encoding defaults.STRING_ENCODING = self.original_string_encoding + +class Attachable(object): + """ + A class that attaches all constructor named parameters as attributes. + + For example-- + + >>> obj = Attachable(foo=42, size="of the universe") + >>> repr(obj) + "Attachable(foo=42, size='of the universe')" + >>> obj.foo + 42 + >>> obj.size + 'of the universe' + + """ + def __init__(self, **kwargs): + self.__args__ = kwargs + for arg, value in kwargs.iteritems(): + setattr(self, arg, value) + + def __repr__(self): + return "%s(%s)" % (self.__class__.__name__, + ", ".join("%s=%s" % (k, repr(v)) + for k, v in self.__args__.iteritems())) diff --git a/pystache/tests/examples/simple.py b/pystache/tests/examples/simple.py index 4e611d0..ea82e9d 100644 --- a/pystache/tests/examples/simple.py +++ b/pystache/tests/examples/simple.py @@ -12,4 +12,4 @@ class Simple(TemplateSpec): return "pizza" def blank(self): - pass + return '' diff --git a/pystache/tests/main.py b/pystache/tests/main.py index 7342c91..de56c44 100644 --- a/pystache/tests/main.py +++ b/pystache/tests/main.py @@ -24,7 +24,9 @@ from pystache.tests.spectesting import get_spec_tests FROM_SOURCE_OPTION = "--from-source" -def run_tests(sys_argv): +# Do not include "test" in this function's name to avoid it getting +# picked up by nosetests. +def main(sys_argv): """ Run all tests in the project. diff --git a/pystache/tests/spectesting.py b/pystache/tests/spectesting.py index d79d75c..ec8a08d 100644 --- a/pystache/tests/spectesting.py +++ b/pystache/tests/spectesting.py @@ -115,6 +115,37 @@ def _read_spec_tests(path): return cases +# TODO: simplify the implementation of this function. +def _convert_children(node): + """ + Recursively convert to functions all "code strings" below the node. + + This function is needed only for the json format. + + """ + if not isinstance(node, (list, dict)): + # Then there is nothing to iterate over and recurse. + return + + if isinstance(node, list): + for child in node: + _convert_children(child) + return + # Otherwise, node is a dict, so attempt the conversion. + + for key in node.keys(): + val = node[key] + + if not isinstance(val, dict) or val.get('__tag__') != 'code': + _convert_children(val) + continue + # Otherwise, we are at a "leaf" node. + + val = eval(val['python']) + node[key] = val + continue + + def _deserialize_spec_test(data, file_path): """ Return a unittest.TestCase instance representing a spec test. @@ -124,7 +155,7 @@ def _deserialize_spec_test(data, file_path): data: the dictionary of attributes for a single test. """ - unconverted_context = data['data'] + context = data['data'] description = data['desc'] # PyYAML seems to leave ASCII strings as byte strings. expected = unicode(data['expected']) @@ -133,13 +164,7 @@ def _deserialize_spec_test(data, file_path): template = data['template'] test_name = data['name'] - # Convert code strings to functions. - # TODO: make this section of code easier to understand. - context = {} - for key, val in unconverted_context.iteritems(): - if isinstance(val, dict) and val.get('__tag__') == 'code': - val = eval(val['python']) - context[key] = val + _convert_children(context) test_case = _make_spec_test(expected, template, context, partials, description, test_name, file_path) diff --git a/pystache/tests/test_context.py b/pystache/tests/test_context.py index 9856fee..0c5097b 100644 --- a/pystache/tests/test_context.py +++ b/pystache/tests/test_context.py @@ -10,8 +10,8 @@ import unittest from pystache.context import _NOT_FOUND from pystache.context import _get_value -from pystache.context import Context -from pystache.tests.common import AssertIsMixin +from pystache.context import ContextStack +from pystache.tests.common import AssertIsMixin, AssertStringMixin, Attachable class SimpleObject(object): @@ -204,10 +204,10 @@ class GetValueTests(unittest.TestCase, AssertIsMixin): self.assertNotFound(item2, 'pop') -class ContextTests(unittest.TestCase, AssertIsMixin): +class ContextStackTests(unittest.TestCase, AssertIsMixin, AssertStringMixin): """ - Test the Context class. + Test the ContextStack class. """ @@ -216,34 +216,34 @@ class ContextTests(unittest.TestCase, AssertIsMixin): Check that passing nothing to __init__() raises no exception. """ - context = Context() + context = ContextStack() def test_init__many_elements(self): """ Check that passing more than two items to __init__() raises no exception. """ - context = Context({}, {}, {}) + context = ContextStack({}, {}, {}) def test__repr(self): - context = Context() - self.assertEqual(repr(context), 'Context()') + context = ContextStack() + self.assertEqual(repr(context), 'ContextStack()') - context = Context({'foo': 'bar'}) - self.assertEqual(repr(context), "Context({'foo': 'bar'},)") + context = ContextStack({'foo': 'bar'}) + self.assertEqual(repr(context), "ContextStack({'foo': 'bar'},)") - context = Context({'foo': 'bar'}, {'abc': 123}) - self.assertEqual(repr(context), "Context({'foo': 'bar'}, {'abc': 123})") + context = ContextStack({'foo': 'bar'}, {'abc': 123}) + self.assertEqual(repr(context), "ContextStack({'foo': 'bar'}, {'abc': 123})") def test__str(self): - context = Context() - self.assertEqual(str(context), 'Context()') + context = ContextStack() + self.assertEqual(str(context), 'ContextStack()') - context = Context({'foo': 'bar'}) - self.assertEqual(str(context), "Context({'foo': 'bar'},)") + context = ContextStack({'foo': 'bar'}) + self.assertEqual(str(context), "ContextStack({'foo': 'bar'},)") - context = Context({'foo': 'bar'}, {'abc': 123}) - self.assertEqual(str(context), "Context({'foo': 'bar'}, {'abc': 123})") + context = ContextStack({'foo': 'bar'}, {'abc': 123}) + self.assertEqual(str(context), "ContextStack({'foo': 'bar'}, {'abc': 123})") ## Test the static create() method. @@ -252,7 +252,7 @@ class ContextTests(unittest.TestCase, AssertIsMixin): Test passing a dictionary. """ - context = Context.create({'foo': 'bar'}) + context = ContextStack.create({'foo': 'bar'}) self.assertEqual(context.get('foo'), 'bar') def test_create__none(self): @@ -260,7 +260,7 @@ class ContextTests(unittest.TestCase, AssertIsMixin): Test passing None. """ - context = Context.create({'foo': 'bar'}, None) + context = ContextStack.create({'foo': 'bar'}, None) self.assertEqual(context.get('foo'), 'bar') def test_create__object(self): @@ -270,16 +270,16 @@ class ContextTests(unittest.TestCase, AssertIsMixin): """ class Foo(object): foo = 'bar' - context = Context.create(Foo()) + context = ContextStack.create(Foo()) self.assertEqual(context.get('foo'), 'bar') def test_create__context(self): """ - Test passing a Context instance. + Test passing a ContextStack instance. """ - obj = Context({'foo': 'bar'}) - context = Context.create(obj) + obj = ContextStack({'foo': 'bar'}) + context = ContextStack.create(obj) self.assertEqual(context.get('foo'), 'bar') def test_create__kwarg(self): @@ -287,7 +287,7 @@ class ContextTests(unittest.TestCase, AssertIsMixin): Test passing a keyword argument. """ - context = Context.create(foo='bar') + context = ContextStack.create(foo='bar') self.assertEqual(context.get('foo'), 'bar') def test_create__precedence_positional(self): @@ -295,7 +295,7 @@ class ContextTests(unittest.TestCase, AssertIsMixin): Test precedence of positional arguments. """ - context = Context.create({'foo': 'bar'}, {'foo': 'buzz'}) + context = ContextStack.create({'foo': 'bar'}, {'foo': 'buzz'}) self.assertEqual(context.get('foo'), 'buzz') def test_create__precedence_keyword(self): @@ -303,7 +303,7 @@ class ContextTests(unittest.TestCase, AssertIsMixin): Test precedence of keyword arguments. """ - context = Context.create({'foo': 'bar'}, foo='buzz') + context = ContextStack.create({'foo': 'bar'}, foo='buzz') self.assertEqual(context.get('foo'), 'buzz') def test_get__key_present(self): @@ -311,7 +311,7 @@ class ContextTests(unittest.TestCase, AssertIsMixin): Test getting a key. """ - context = Context({"foo": "bar"}) + context = ContextStack({"foo": "bar"}) self.assertEqual(context.get("foo"), "bar") def test_get__key_missing(self): @@ -319,15 +319,15 @@ class ContextTests(unittest.TestCase, AssertIsMixin): Test getting a missing key. """ - context = Context() - self.assertTrue(context.get("foo") is None) + context = ContextStack() + self.assertString(context.get("foo"), u'') def test_get__default(self): """ Test that get() respects the default value. """ - context = Context() + context = ContextStack() self.assertEqual(context.get("foo", "bar"), "bar") def test_get__precedence(self): @@ -335,7 +335,7 @@ class ContextTests(unittest.TestCase, AssertIsMixin): Test that get() respects the order of precedence (later items first). """ - context = Context({"foo": "bar"}, {"foo": "buzz"}) + context = ContextStack({"foo": "bar"}, {"foo": "buzz"}) self.assertEqual(context.get("foo"), "buzz") def test_get__fallback(self): @@ -343,7 +343,7 @@ class ContextTests(unittest.TestCase, AssertIsMixin): Check that first-added stack items are queried on context misses. """ - context = Context({"fuzz": "buzz"}, {"foo": "bar"}) + context = ContextStack({"fuzz": "buzz"}, {"foo": "bar"}) self.assertEqual(context.get("fuzz"), "buzz") def test_push(self): @@ -352,7 +352,7 @@ class ContextTests(unittest.TestCase, AssertIsMixin): """ key = "foo" - context = Context({key: "bar"}) + context = ContextStack({key: "bar"}) self.assertEqual(context.get(key), "bar") context.push({key: "buzz"}) @@ -364,7 +364,7 @@ class ContextTests(unittest.TestCase, AssertIsMixin): """ key = "foo" - context = Context({key: "bar"}, {key: "buzz"}) + context = ContextStack({key: "bar"}, {key: "buzz"}) self.assertEqual(context.get(key), "buzz") item = context.pop() @@ -373,7 +373,7 @@ class ContextTests(unittest.TestCase, AssertIsMixin): def test_top(self): key = "foo" - context = Context({key: "bar"}, {key: "buzz"}) + context = ContextStack({key: "bar"}, {key: "buzz"}) self.assertEqual(context.get(key), "buzz") top = context.top() @@ -383,7 +383,7 @@ class ContextTests(unittest.TestCase, AssertIsMixin): def test_copy(self): key = "foo" - original = Context({key: "bar"}, {key: "buzz"}) + original = ContextStack({key: "bar"}, {key: "buzz"}) self.assertEqual(original.get(key), "buzz") new = original.copy() @@ -395,3 +395,76 @@ class ContextTests(unittest.TestCase, AssertIsMixin): # Confirm the original is unchanged. self.assertEqual(original.get(key), "buzz") + def test_dot_notation__dict(self): + name = "foo.bar" + stack = ContextStack({"foo": {"bar": "baz"}}) + self.assertEqual(stack.get(name), "baz") + + # Works all the way down + name = "a.b.c.d.e.f.g" + stack = ContextStack({"a": {"b": {"c": {"d": {"e": {"f": {"g": "w00t!"}}}}}}}) + self.assertEqual(stack.get(name), "w00t!") + + def test_dot_notation__user_object(self): + name = "foo.bar" + stack = ContextStack({"foo": Attachable(bar="baz")}) + self.assertEquals(stack.get(name), "baz") + + # Works on multiple levels, too + name = "a.b.c.d.e.f.g" + A = Attachable + stack = ContextStack({"a": A(b=A(c=A(d=A(e=A(f=A(g="w00t!"))))))}) + self.assertEquals(stack.get(name), "w00t!") + + def test_dot_notation__mixed_dict_and_obj(self): + name = "foo.bar.baz.bak" + stack = ContextStack({"foo": Attachable(bar={"baz": Attachable(bak=42)})}) + self.assertEquals(stack.get(name), 42) + + def test_dot_notation__missing_attr_or_key(self): + name = "foo.bar.baz.bak" + stack = ContextStack({"foo": {"bar": {}}}) + self.assertString(stack.get(name), u'') + + stack = ContextStack({"foo": Attachable(bar=Attachable())}) + self.assertString(stack.get(name), u'') + + def test_dot_notation__missing_part_terminates_search(self): + """ + Test that dotted name resolution terminates on a later part not found. + + Check that if a later dotted name part is not found in the result from + the former resolution, then name resolution terminates rather than + starting the search over with the next element of the context stack. + From the spec (interpolation section)-- + + 5) If any name parts were retained in step 1, each should be resolved + against a context stack containing only the result from the former + resolution. If any part fails resolution, the result should be considered + falsey, and should interpolate as the empty string. + + This test case is equivalent to the test case in the following pull + request: + + https://github.com/mustache/spec/pull/48 + + """ + stack = ContextStack({'a': {'b': 'A.B'}}, {'a': 'A'}) + self.assertEqual(stack.get('a'), 'A') + self.assertString(stack.get('a.b'), u'') + stack.pop() + self.assertEqual(stack.get('a.b'), 'A.B') + + def test_dot_notation__autocall(self): + name = "foo.bar.baz" + + # When any element in the path is callable, it should be automatically invoked + stack = ContextStack({"foo": Attachable(bar=Attachable(baz=lambda: "Called!"))}) + self.assertEquals(stack.get(name), "Called!") + + class Foo(object): + def bar(self): + return Attachable(baz='Baz') + + stack = ContextStack({"foo": Foo()}) + self.assertEquals(stack.get(name), "Baz") diff --git a/pystache/tests/test_locator.py b/pystache/tests/test_locator.py index 3a8b229..f17a289 100644 --- a/pystache/tests/test_locator.py +++ b/pystache/tests/test_locator.py @@ -11,14 +11,15 @@ import sys import unittest # TODO: remove this alias. +from pystache.common import TemplateNotFoundError from pystache.loader import Loader as Reader from pystache.locator import Locator -from pystache.tests.common import DATA_DIR, EXAMPLES_DIR +from pystache.tests.common import DATA_DIR, EXAMPLES_DIR, AssertExceptionMixin from pystache.tests.data.views import SayHello -class LocatorTests(unittest.TestCase): +class LocatorTests(unittest.TestCase, AssertExceptionMixin): def _locator(self): return Locator(search_dirs=DATA_DIR) @@ -110,7 +111,8 @@ class LocatorTests(unittest.TestCase): def test_find_name__non_existent_template_fails(self): locator = Locator() - self.assertRaises(IOError, locator.find_name, search_dirs=[], template_name='doesnt_exist') + self.assertException(TemplateNotFoundError, "File 'doesnt_exist.mustache' not found in dirs: []", + locator.find_name, search_dirs=[], template_name='doesnt_exist') def test_find_object(self): locator = Locator() diff --git a/pystache/tests/test_parser.py b/pystache/tests/test_parser.py new file mode 100644 index 0000000..4aa0959 --- /dev/null +++ b/pystache/tests/test_parser.py @@ -0,0 +1,26 @@ +# coding: utf-8 + +""" +Unit tests of parser.py. + +""" + +import unittest + +from pystache.parser import _compile_template_re as make_re + + +class RegularExpressionTestCase(unittest.TestCase): + + """Tests the regular expression returned by _compile_template_re().""" + + def test_re(self): + """ + Test getting a key from a dictionary. + + """ + re = make_re() + match = re.search("b {{test}}") + + self.assertEqual(match.start(), 1) + diff --git a/pystache/tests/test_renderengine.py b/pystache/tests/test_renderengine.py index 3c63cfb..d7f6bf7 100644 --- a/pystache/tests/test_renderengine.py +++ b/pystache/tests/test_renderengine.py @@ -7,11 +7,11 @@ Unit tests of renderengine.py. import unittest -from pystache.context import Context +from pystache.context import ContextStack from pystache import defaults from pystache.parser import ParsingError from pystache.renderengine import RenderEngine -from pystache.tests.common import AssertStringMixin +from pystache.tests.common import AssertStringMixin, Attachable def mock_literal(s): @@ -83,7 +83,7 @@ class RenderTests(unittest.TestCase, AssertStringMixin): if partials is not None: engine.load_partial = lambda key: unicode(partials[key]) - context = Context(*context) + context = ContextStack(*context) actual = engine.render(template, context) @@ -204,6 +204,27 @@ class RenderTests(unittest.TestCase, AssertStringMixin): context = {'test': '{{#hello}}'} self._assert_render(u'{{#hello}}', template, context) + ## Test interpolation with "falsey" values + # + # In these test cases, we test the part of the spec that says that + # "data should be coerced into a string (and escaped, if appropriate) + # before interpolation." We test this for data that is "falsey." + + def test_interpolation__falsey__zero(self): + template = '{{.}}' + context = 0 + self._assert_render(u'0', template, context) + + def test_interpolation__falsey__none(self): + template = '{{.}}' + context = None + self._assert_render(u'None', template, context) + + def test_interpolation__falsey__zero(self): + template = '{{.}}' + context = False + self._assert_render(u'False', template, context) + # Built-in types: # # Confirm that we not treat instances of built-in types as objects, @@ -480,6 +501,56 @@ class RenderTests(unittest.TestCase, AssertStringMixin): context = {'person': 'Mom', 'test': (lambda text: text + " :)")} self._assert_render(u'Hi Mom :)', template, context) + def test_section__lambda__list(self): + """ + Check that lists of lambdas are processed correctly for sections. + + This test case is equivalent to a test submitted to the Mustache spec here: + + https://github.com/mustache/spec/pull/47 . + + """ + template = '<{{#lambdas}}foo{{/lambdas}}>' + context = {'foo': 'bar', + 'lambdas': [lambda text: "~{{%s}}~" % text, + lambda text: "#{{%s}}#" % text]} + + self._assert_render(u'<~bar~#bar#>', template, context) + + def test_section__lambda__not_on_context_stack(self): + """ + Check that section lambdas are not pushed onto the context stack. + + Even though the sections spec says that section data values should be + pushed onto the context stack prior to rendering, this does not apply + to lambdas. Lambdas obey their own special case. + + This test case is equivalent to a test submitted to the Mustache spec here: + + https://github.com/mustache/spec/pull/47 . + + """ + context = {'foo': 'bar', 'lambda': (lambda text: "{{.}}")} + template = '{{#foo}}{{#lambda}}blah{{/lambda}}{{/foo}}' + self._assert_render(u'bar', template, context) + + def test_section__lambda__no_reinterpolation(self): + """ + Check that section lambda return values are not re-interpolated. + + This test is a sanity check that the rendered lambda return value + is not re-interpolated as could be construed by reading the + section part of the Mustache spec. + + This test case is equivalent to a test submitted to the Mustache spec here: + + https://github.com/mustache/spec/pull/47 . + + """ + template = '{{#planet}}{{#lambda}}dot{{/lambda}}{{/planet}}' + context = {'planet': 'Earth', 'dot': '~{{.}}~', 'lambda': (lambda text: "#{{%s}}#" % text)} + self._assert_render(u'#~{{.}}~#', template, context) + def test_comment__multiline(self): """ Check that multiline comments are permitted. @@ -509,3 +580,78 @@ class RenderTests(unittest.TestCase, AssertStringMixin): expected = u' {{foo}} ' self._assert_render(expected, '{{=$ $=}} {{foo}} ') self._assert_render(expected, '{{=$ $=}} {{foo}} $={{ }}=$') # was yielding u' '. + + def test_dot_notation(self): + """ + Test simple dot notation cases. + + Check that we can use dot notation when the variable is a dict, + user-defined object, or combination of both. + + """ + template = 'Hello, {{person.name}}. I see you are {{person.details.age}}.' + person = Attachable(name='Biggles', details={'age': 42}) + context = {'person': person} + self._assert_render(u'Hello, Biggles. I see you are 42.', template, context) + + def test_dot_notation__missing_attributes_or_keys(self): + """ + Test dot notation with missing keys or attributes. + + Check that if a key or attribute in a dotted name does not exist, then + the tag renders as the empty string. + + """ + template = """I cannot see {{person.name}}'s age: {{person.age}}. + Nor {{other_person.name}}'s: .""" + expected = u"""I cannot see Biggles's age: . + Nor Mr. Bradshaw's: .""" + context = {'person': {'name': 'Biggles'}, + 'other_person': Attachable(name='Mr. Bradshaw')} + self._assert_render(expected, template, context) + + def test_dot_notation__multiple_levels(self): + """ + Test dot notation with multiple levels. + + """ + template = """Hello, Mr. {{person.name.lastname}}. + I see you're back from {{person.travels.last.country.city}}. + I'm missing some of your details: {{person.details.private.editor}}.""" + expected = u"""Hello, Mr. Pither. + I see you're back from Cornwall. + I'm missing some of your details: .""" + context = {'person': {'name': {'firstname': 'unknown', 'lastname': 'Pither'}, + 'travels': {'last': {'country': {'city': 'Cornwall'}}}, + 'details': {'public': 'likes cycling'}}} + self._assert_render(expected, template, context) + + # It should also work with user-defined objects + context = {'person': Attachable(name={'firstname': 'unknown', 'lastname': 'Pither'}, + travels=Attachable(last=Attachable(country=Attachable(city='Cornwall'))), + details=Attachable())} + self._assert_render(expected, template, context) + + def test_dot_notation__missing_part_terminates_search(self): + """ + Test that dotted name resolution terminates on a later part not found. + + Check that if a later dotted name part is not found in the result from + the former resolution, then name resolution terminates rather than + starting the search over with the next element of the context stack. + From the spec (interpolation section)-- + + 5) If any name parts were retained in step 1, each should be resolved + against a context stack containing only the result from the former + resolution. If any part fails resolution, the result should be considered + falsey, and should interpolate as the empty string. + + This test case is equivalent to the test case in the following pull + request: + + https://github.com/mustache/spec/pull/48 + + """ + template = '{{a.b}} :: ({{#c}}{{a}} :: {{a.b}}{{/c}})' + context = {'a': {'b': 'A.B'}, 'c': {'a': 'A'} } + self._assert_render(u'A.B :: (A :: )', template, context) diff --git a/pystache/tests/test_renderer.py b/pystache/tests/test_renderer.py index 64a4325..f04c799 100644 --- a/pystache/tests/test_renderer.py +++ b/pystache/tests/test_renderer.py @@ -13,9 +13,10 @@ import unittest from examples.simple import Simple from pystache import Renderer from pystache import TemplateSpec +from pystache.common import TemplateNotFoundError from pystache.loader import Loader -from pystache.tests.common import get_data_path, AssertStringMixin +from pystache.tests.common import get_data_path, AssertStringMixin, AssertExceptionMixin from pystache.tests.data.views import SayHello @@ -405,7 +406,7 @@ class RendererTests(unittest.TestCase, AssertStringMixin): # we no longer need to exercise all rendering code paths through # the Renderer. It suffices to test rendering paths through the # RenderEngine for the same amount of code coverage. -class Renderer_MakeRenderEngineTests(unittest.TestCase): +class Renderer_MakeRenderEngineTests(unittest.TestCase, AssertExceptionMixin): """ Check the RenderEngine returned by Renderer._make_render_engine(). @@ -444,7 +445,20 @@ class Renderer_MakeRenderEngineTests(unittest.TestCase): self.assertEqual(actual, "abc") self.assertEqual(type(actual), unicode) - def test__load_partial__not_found(self): + def test__load_partial__not_found__default(self): + """ + Check that load_partial provides a nice message when a template is not found. + + """ + renderer = Renderer() + + engine = renderer._make_render_engine() + load_partial = engine.load_partial + + self.assertException(TemplateNotFoundError, "File 'foo.mustache' not found in dirs: ['.']", + load_partial, "foo") + + def test__load_partial__not_found__dict(self): """ Check that load_partial provides a nice message when a template is not found. @@ -455,11 +469,10 @@ class Renderer_MakeRenderEngineTests(unittest.TestCase): engine = renderer._make_render_engine() load_partial = engine.load_partial - try: - load_partial("foo") - raise Exception("Shouldn't get here") - except Exception, err: - self.assertEqual(str(err), "Partial not found with name: 'foo'") + # Include dict directly since str(dict) is different in Python 2 and 3: + # <type 'dict'> versus <class 'dict'>, respectively. + self.assertException(TemplateNotFoundError, "Name 'foo' not found in partials: %s" % dict, + load_partial, "foo") ## Test the engine's literal attribute. diff --git a/pystache/tests/test_specloader.py b/pystache/tests/test_specloader.py index 8332b28..24fb34d 100644 --- a/pystache/tests/test_specloader.py +++ b/pystache/tests/test_specloader.py @@ -16,6 +16,7 @@ from examples.lambdas import Lambdas from examples.inverted import Inverted, InvertedLists from pystache import Renderer from pystache import TemplateSpec +from pystache.common import TemplateNotFoundError from pystache.locator import Locator from pystache.loader import Loader from pystache.specloader import SpecLoader @@ -42,7 +43,7 @@ class ViewTestCase(unittest.TestCase, AssertStringMixin): view = Tagless() renderer = Renderer() - self.assertRaises(IOError, renderer.render, view) + self.assertRaises(TemplateNotFoundError, renderer.render, view) # TODO: change this test to remove the following brittle line. view.template_rel_directory = "examples" @@ -60,7 +61,8 @@ class ViewTestCase(unittest.TestCase, AssertStringMixin): renderer1 = Renderer() renderer2 = Renderer(search_dirs=EXAMPLES_DIR) - self.assertRaises(IOError, renderer1.render, spec) + actual = renderer1.render(spec) + self.assertString(actual, u"Partial: ") actual = renderer2.render(spec) self.assertEqual(actual, "Partial: No tags...") |