summaryrefslogtreecommitdiff
path: root/pystache
diff options
context:
space:
mode:
authorChris Jerdonek <chris.jerdonek@gmail.com>2012-05-03 15:53:54 -0700
committerChris Jerdonek <chris.jerdonek@gmail.com>2012-05-03 15:53:54 -0700
commit8bc8baf31ae4869df1ca0ef3b9c8837d0a075d9d (patch)
tree364c2111e301c455569a3a59a12d89a10fbe1ce8 /pystache
parentcc262abf19cd90e34390d5ddb5db30d6f04620fa (diff)
parent3c839348727aab1da9ee48e532124083df3a364d (diff)
downloadpystache-8bc8baf31ae4869df1ca0ef3b9c8837d0a075d9d.tar.gz
Merge branch 'development' into 'master': staging v0.5.2-rc
Diffstat (limited to 'pystache')
-rw-r--r--pystache/__init__.py2
-rw-r--r--pystache/commands/render.py3
-rw-r--r--pystache/commands/test.py2
-rw-r--r--pystache/common.py12
-rw-r--r--pystache/context.py172
-rw-r--r--pystache/locator.py6
-rw-r--r--pystache/parsed.py5
-rw-r--r--pystache/parser.py74
-rw-r--r--pystache/renderengine.py64
-rw-r--r--pystache/renderer.py12
-rw-r--r--pystache/tests/common.py39
-rw-r--r--pystache/tests/examples/simple.py2
-rw-r--r--pystache/tests/main.py4
-rw-r--r--pystache/tests/spectesting.py41
-rw-r--r--pystache/tests/test_context.py147
-rw-r--r--pystache/tests/test_locator.py8
-rw-r--r--pystache/tests/test_parser.py26
-rw-r--r--pystache/tests/test_renderengine.py152
-rw-r--r--pystache/tests/test_renderer.py29
-rw-r--r--pystache/tests/test_specloader.py6
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...")