diff options
author | Rodrigo Bernardo Pimentel <rbp@isnomore.net> | 2012-04-29 18:16:19 +0200 |
---|---|---|
committer | Rodrigo Bernardo Pimentel <rbp@isnomore.net> | 2012-04-29 18:16:19 +0200 |
commit | 012bdba1d29c37c3b0bbdd889ab7f6717c649856 (patch) | |
tree | 1fa4bbd1d32457ba1ef9eabe345e7ab7167d551f /pystache | |
parent | 544b7a35e6266bf0bc3d50168f6611d1ffc91f4a (diff) | |
parent | 54eb4b5047bc75a596c9734f460726aa462aea2e (diff) | |
download | pystache-012bdba1d29c37c3b0bbdd889ab7f6717c649856.tar.gz |
Merge branch 'development' of https://github.com/defunkt/pystache into development
Conflicts:
pystache/context.py
pystache/tests/test_context.py
pystache/tests/test_renderengine.py
tests/common.py
Diffstat (limited to 'pystache')
81 files changed, 4286 insertions, 94 deletions
diff --git a/pystache/__init__.py b/pystache/__init__.py index daf7f52..5f5035d 100644 --- a/pystache/__init__.py +++ b/pystache/__init__.py @@ -1,2 +1,13 @@ + +""" +TODO: add a docstring. + +""" + # We keep all initialization code in a separate module. -from init import * + +from pystache.init import render, Renderer, TemplateSpec + +__all__ = ['render', 'Renderer', 'TemplateSpec'] + +__version__ = '0.5.1' # Also change in setup.py. diff --git a/pystache/commands/__init__.py b/pystache/commands/__init__.py new file mode 100644 index 0000000..a0d386a --- /dev/null +++ b/pystache/commands/__init__.py @@ -0,0 +1,4 @@ +""" +TODO: add a docstring. + +""" diff --git a/pystache/commands.py b/pystache/commands/render.py index 1801d40..23b19f8 100644 --- a/pystache/commands.py +++ b/pystache/commands/render.py @@ -13,7 +13,16 @@ try: except: # The json module is new in Python 2.6, whereas simplejson is # compatible with earlier versions. - import simplejson as json + try: + import simplejson as json + except ImportError: + # Raise an error with a type different from ImportError as a hack around + # this issue: + # http://bugs.python.org/issue7559 + from sys import exc_info + ex_type, ex_value, tb = exc_info() + new_ex = Exception("%s: %s" % (ex_type.__name__, ex_value)) + raise new_ex.__class__, new_ex, tb # The optparse module is deprecated in Python 2.7 in favor of argparse. # However, argparse is not available in Python 2.6 and earlier. @@ -54,7 +63,12 @@ def parse_args(sys_argv, usage): return template, context -def main(sys_argv): +# TODO: verify whether the setup() method's entry_points argument +# supports passing arguments to main: +# +# http://packages.python.org/distribute/setuptools.html#automatic-script-creation +# +def main(sys_argv=sys.argv): template, context = parse_args(sys_argv, USAGE) if template.endswith('.mustache'): @@ -77,5 +91,4 @@ def main(sys_argv): if __name__=='__main__': - main(sys.argv) - + main() diff --git a/pystache/commands/test.py b/pystache/commands/test.py new file mode 100644 index 0000000..0872453 --- /dev/null +++ b/pystache/commands/test.py @@ -0,0 +1,18 @@ +# coding: utf-8 + +""" +This module provides a command to test pystache (unit tests, doctests, etc). + +""" + +import sys + +from pystache.tests.main import main as run_tests + + +def main(sys_argv=sys.argv): + run_tests(sys_argv=sys_argv) + + +if __name__=='__main__': + main() diff --git a/pystache/common.py b/pystache/common.py new file mode 100644 index 0000000..00f8a77 --- /dev/null +++ b/pystache/common.py @@ -0,0 +1,26 @@ +# coding: utf-8 + +""" +Exposes common functions. + +""" + +# This function was designed to be portable across Python versions -- both +# with older versions and with Python 3 after applying 2to3. +def read(path): + """ + Return the contents of a text file as a byte string. + + """ + # Opening in binary mode is necessary for compatibility across Python + # 2 and 3. In both Python 2 and 3, open() defaults to opening files in + # text mode. However, in Python 2, open() returns file objects whose + # read() method returns byte strings (strings of type `str` in Python 2), + # whereas in Python 3, the file object returns unicode strings (strings + # of type `str` in Python 3). + f = open(path, 'rb') + # We avoid use of the with keyword for Python 2.4 support. + try: + return f.read() + finally: + f.close() diff --git a/pystache/context.py b/pystache/context.py index d5570da..d0cba5d 100644 --- a/pystache/context.py +++ b/pystache/context.py @@ -1,15 +1,24 @@ # coding: utf-8 """ -Defines a Context class to represent mustache(5)'s notion of context. +Exposes a ContextStack class and functions to retrieve names from context. """ -class NotFound(object): pass +# This equals '__builtin__' in Python 2 and 'builtins' in Python 3. +_BUILTIN_MODULE = type(0).__module__ + + # We use this private global variable as a return value to represent a key # not being found on lookup. This lets us distinguish between the case # of a key's value being None with the case of a key not being found -- # without having to rely on exceptions (e.g. KeyError) for flow control. +# +# TODO: eliminate the need for a private global variable, e.g. by using the +# preferred Python approach of "easier to ask for forgiveness than permission": +# http://docs.python.org/glossary.html#term-eafp +class NotFound(object): + pass _NOT_FOUND = NotFound() @@ -24,7 +33,7 @@ def _get_value(item, key): 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. """ parts = key.split('.') @@ -39,7 +48,7 @@ def _get_value(item, key): # (e.g. catching KeyError). if key in item: value = item[key] - elif type(item).__module__ != '__builtin__': + elif type(item).__module__ != _BUILTIN_MODULE: # Then we consider the argument an "object" for the purposes of # the spec. # @@ -60,7 +69,26 @@ def _get_value(item, key): return value -class Context(object): +# TODO: add some unit tests for this. +def resolve(context, name): + """ + Resolve the given name against the given context stack. + + This function follows the rules outlined in the section of the spec + regarding tag interpolation. + + This function does not coerce the return value to a string. + + """ + if name == '.': + return context.top() + + # The spec says that if the name fails resolution, the result should be + # considered falsey, and should interpolate as the empty string. + return context.get(name, '') + + +class ContextStack(object): """ Provides dictionary-like access to a stack of zero or more items. @@ -75,7 +103,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 +120,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 +132,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 +142,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 +164,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 +180,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) @@ -226,9 +254,9 @@ 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. @@ -270,4 +298,4 @@ class Context(object): Return a copy of this instance. """ - return Context(*self._stack) + return ContextStack(*self._stack) diff --git a/pystache/defaults.py b/pystache/defaults.py index b696410..fcd04c3 100644 --- a/pystache/defaults.py +++ b/pystache/defaults.py @@ -8,7 +8,12 @@ does not otherwise specify a value. """ -import cgi +try: + # Python 3.2 adds html.escape() and deprecates cgi.escape(). + from html import escape +except ImportError: + from cgi import escape + import os import sys @@ -39,12 +44,14 @@ SEARCH_DIRS = [os.curdir] # i.e. ['.'] # rendering templates (e.g. for tags enclosed in double braces). # Only unicode strings will be passed to this function. # -# The quote=True argument causes double quotes to be escaped, -# but not single quotes: +# The quote=True argument causes double but not single quotes to be escaped +# in Python 3.1 and earlier, and both double and single quotes to be +# escaped in Python 3.2 and later: # # http://docs.python.org/library/cgi.html#cgi.escape +# http://docs.python.org/dev/library/html.html#html.escape # -TAG_ESCAPE = lambda u: cgi.escape(u, quote=True) +TAG_ESCAPE = lambda u: escape(u, quote=True) # The default template extension. TEMPLATE_EXTENSION = 'mustache' diff --git a/pystache/init.py b/pystache/init.py index b285a5c..e9d854d 100644 --- a/pystache/init.py +++ b/pystache/init.py @@ -9,9 +9,6 @@ from pystache.renderer import Renderer from pystache.template_spec import TemplateSpec -__all__ = ['render', 'Renderer', 'TemplateSpec'] - - def render(template, context=None, **kwargs): """ Return the given template string rendered using the given context. diff --git a/pystache/loader.py b/pystache/loader.py index bcba71b..0fdadc5 100644 --- a/pystache/loader.py +++ b/pystache/loader.py @@ -8,18 +8,24 @@ This module provides a Loader class for locating and reading templates. import os import sys +from pystache import common from pystache import defaults from pystache.locator import Locator -def _to_unicode(s, encoding=None): - """ - Raises a TypeError exception if the given string is already unicode. +# We make a function so that the current defaults take effect. +# TODO: revisit whether this is necessary. - """ - if encoding is None: - encoding = defaults.STRING_ENCODING - return unicode(s, encoding, defaults.DECODE_ERRORS) +def _make_to_unicode(): + def to_unicode(s, encoding=None): + """ + Raises a TypeError exception if the given string is already unicode. + + """ + if encoding is None: + encoding = defaults.STRING_ENCODING + return unicode(s, encoding, defaults.DECODE_ERRORS) + return to_unicode class Loader(object): @@ -67,7 +73,7 @@ class Loader(object): search_dirs = defaults.SEARCH_DIRS if to_unicode is None: - to_unicode = _to_unicode + to_unicode = _make_to_unicode() self.extension = extension self.file_encoding = file_encoding @@ -106,17 +112,12 @@ class Loader(object): Read the template at the given path, and return it as a unicode string. """ - # We avoid use of the with keyword for Python 2.4 support. - f = open(path, 'r') - try: - text = f.read() - finally: - f.close() + b = common.read(path) if encoding is None: encoding = self.file_encoding - return self.unicode(text, encoding) + return self.unicode(b, encoding) # TODO: unit-test this method. def load_name(self, name): diff --git a/pystache/parsed.py b/pystache/parsed.py index 5418ec1..552af55 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 diff --git a/pystache/parser.py b/pystache/parser.py index d07ebf6..2b97405 100644 --- a/pystache/parser.py +++ b/pystache/parser.py @@ -9,7 +9,7 @@ This module is only meant for internal use by the renderengine module. import re -from parsed import ParsedTemplate +from pystache.parsed import ParsedTemplate DEFAULT_DELIMITERS = ('{{', '}}') @@ -17,7 +17,13 @@ END_OF_LINE_CHARACTERS = ['\r', '\n'] NON_BLANK_RE = re.compile(r'^(.)', 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 +80,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) @@ -131,9 +143,9 @@ class Parser(object): if tag_type == '/': if tag_key != section_key: - raise ParsingError("Section end tag mismatch: %s != %s" % (repr(tag_key), repr(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 +154,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,12 +205,12 @@ 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 == '>': diff --git a/pystache/renderengine.py b/pystache/renderengine.py index 4361dca..9e4da11 100644 --- a/pystache/renderengine.py +++ b/pystache/renderengine.py @@ -7,7 +7,8 @@ Defines a class responsible for rendering logic. import re -from parser import Parser +from pystache.context import resolve +from pystache.parser import Parser class RenderEngine(object): @@ -55,7 +56,7 @@ class RenderEngine(object): this class will not pass tag values to literal prior to passing them to this function. This allows for more flexibility, for example using a custom escape function that handles - incoming strings of type markupssafe.Markup differently + incoming strings of type markupsafe.Markup differently from plain unicode strings. """ @@ -68,16 +69,7 @@ class RenderEngine(object): Get a value from the given context as a basestring instance. """ - 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() + val = resolve(context, tag_name) if callable(val): # According to the spec: @@ -142,6 +134,8 @@ 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) if data: return u'' @@ -167,9 +161,33 @@ class RenderEngine(object): # TODO: should we check the arity? template = data(template) parsed_template = self._parse(template, delimiters=delims) - data = [ data ] - elif not hasattr(data, '__iter__') or isinstance(data, dict): - data = [ data ] + # Lambdas special case section rendering and bypass pushing + # the data value onto the context stack. Also see-- + # + # https://github.com/defunkt/pystache/issues/113 + # + return parsed_template.render(context) + else: + # The cleanest, least brittle way of determining whether + # something supports iteration is by trying to call iter() on it: + # + # http://docs.python.org/library/functions.html#iter + # + # It is not sufficient, for example, to check whether the item + # implements __iter__ () (the iteration protocol). There is + # also __getitem__() (the sequence protocol). In Python 2, + # strings do not implement __iter__(), but in Python 3 they do. + try: + iter(data) + except TypeError: + # 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)): + data = [data] + # Otherwise, leave it alone. parts = [] for element in data: @@ -202,7 +220,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 @@ -225,7 +243,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 5bd2a3f..26f271f 100644 --- a/pystache/renderer.py +++ b/pystache/renderer.py @@ -5,14 +5,27 @@ This module provides a Renderer class to render templates. """ +import sys + from pystache import defaults -from pystache.context import Context +from pystache.context import ContextStack from pystache.loader import Loader from pystache.renderengine import RenderEngine -from pystache.spec_loader import SpecLoader +from pystache.specloader import SpecLoader from pystache.template_spec import TemplateSpec +# TODO: come up with a better solution for this. One of the issues here +# is that in Python 3 there is no common base class for unicode strings +# and byte strings, and 2to3 seems to convert all of "str", "unicode", +# and "basestring" to Python 3's "str". +if sys.version_info < (3, ): + _STRING_TYPES = basestring +else: + # The latter evaluates to "bytes" in Python 3 -- even after conversion by 2to3. + _STRING_TYPES = (unicode, type(u"a".encode('utf-8'))) + + class Renderer(object): """ @@ -27,8 +40,9 @@ class Renderer(object): >>> partials = {'partial': 'Hello, {{thing}}!'} >>> renderer = Renderer(partials=partials) - >>> renderer.render('{{>partial}}', {'thing': 'world'}) - u'Hello, world!' + >>> # We apply print to make the test work in Python 3 after 2to3. + >>> print renderer.render('{{>partial}}', {'thing': 'world'}) + Hello, world! """ @@ -64,10 +78,10 @@ class Renderer(object): this class will only pass it unicode strings. The constructor assigns this function to the constructed instance's escape() method. - The argument defaults to `cgi.escape(s, quote=True)`. To - disable escaping entirely, one can pass `lambda u: u` as the - escape function, for example. One may also wish to consider - using markupsafe's escape function: markupsafe.escape(). + To disable escaping entirely, one can pass `lambda u: u` + as the escape function, for example. One may also wish to + consider using markupsafe's escape function: markupsafe.escape(). + This argument defaults to the package default. file_encoding: the name of the default encoding to use when reading template files. All templates are converted to unicode prior @@ -160,9 +174,16 @@ class Renderer(object): """ return unicode(self.escape(self._to_unicode_soft(s))) - def unicode(self, s, encoding=None): + def unicode(self, b, encoding=None): """ - Convert a string to unicode, using string_encoding and decode_errors. + Convert a byte string to unicode, using string_encoding and decode_errors. + + Arguments: + + b: a byte string. + + encoding: the name of an encoding. Defaults to the string_encoding + attribute for this instance. Raises: @@ -178,7 +199,7 @@ class Renderer(object): # TODO: Wrap UnicodeDecodeErrors with a message about setting # the string_encoding and decode_errors attributes. - return unicode(s, encoding, self.decode_errors) + return unicode(b, encoding, self.decode_errors) def _make_loader(self): """ @@ -256,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() @@ -317,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 @@ -329,7 +350,7 @@ class Renderer(object): all items in the *context list. """ - if isinstance(template, basestring): + if isinstance(template, _STRING_TYPES): return self._render_string(template, *context, **kwargs) # Otherwise, we assume the template is an object. diff --git a/pystache/spec_loader.py b/pystache/specloader.py index 3cb0f1a..3cb0f1a 100644 --- a/pystache/spec_loader.py +++ b/pystache/specloader.py diff --git a/pystache/template_spec.py b/pystache/template_spec.py index c33f30b..76ce784 100644 --- a/pystache/template_spec.py +++ b/pystache/template_spec.py @@ -1,7 +1,11 @@ # coding: utf-8 """ -This module supports customized (aka special or specified) template loading. +Provides a class to customize template information on a per-view basis. + +To customize template properties for a particular view, create that view +from a class that subclasses TemplateSpec. The "Spec" in TemplateSpec +stands for template information that is "special" or "specified". """ diff --git a/pystache/tests/__init__.py b/pystache/tests/__init__.py new file mode 100644 index 0000000..a0d386a --- /dev/null +++ b/pystache/tests/__init__.py @@ -0,0 +1,4 @@ +""" +TODO: add a docstring. + +""" diff --git a/pystache/tests/benchmark.py b/pystache/tests/benchmark.py new file mode 100755 index 0000000..d46e973 --- /dev/null +++ b/pystache/tests/benchmark.py @@ -0,0 +1,94 @@ +#!/usr/bin/env python +# coding: utf-8 + +""" +A rudimentary backward- and forward-compatible script to benchmark pystache. + +Usage: + +tests/benchmark.py 10000 + +""" + +import sys +from timeit import Timer + +import pystache + +# TODO: make the example realistic. + +examples = [ + # Test case: 1 + ("""{{#person}}Hi {{name}}{{/person}}""", + {"person": {"name": "Jon"}}, + "Hi Jon"), + + # Test case: 2 + ("""\ +<div class="comments"> +<h3>{{header}}</h3> +<ul> +{{#comments}}<li class="comment"> +<h5>{{name}}</h5><p>{{body}}</p> +</li>{{/comments}} +</ul> +</div>""", + {'header': "My Post Comments", + 'comments': [ + {'name': "Joe", 'body': "Thanks for this post!"}, + {'name': "Sam", 'body': "Thanks for this post!"}, + {'name': "Heather", 'body': "Thanks for this post!"}, + {'name': "Kathy", 'body': "Thanks for this post!"}, + {'name': "George", 'body': "Thanks for this post!"}]}, + """\ +<div class="comments"> +<h3>My Post Comments</h3> +<ul> +<li class="comment"> +<h5>Joe</h5><p>Thanks for this post!</p> +</li><li class="comment"> +<h5>Sam</h5><p>Thanks for this post!</p> +</li><li class="comment"> +<h5>Heather</h5><p>Thanks for this post!</p> +</li><li class="comment"> +<h5>Kathy</h5><p>Thanks for this post!</p> +</li><li class="comment"> +<h5>George</h5><p>Thanks for this post!</p> +</li> +</ul> +</div>"""), +] + + +def make_test_function(example): + + template, context, expected = example + + def test(): + actual = pystache.render(template, context) + if actual != expected: + raise Exception("Benchmark mismatch: \n%s\n*** != ***\n%s" % (expected, actual)) + + return test + + +def main(sys_argv): + args = sys_argv[1:] + count = int(args[0]) + + print "Benchmarking: %sx" % count + print + + for example in examples: + + test = make_test_function(example) + + t = Timer(test,) + print min(t.repeat(repeat=3, number=count)) + + print "Done" + + +if __name__ == '__main__': + main(sys.argv) + diff --git a/pystache/tests/common.py b/pystache/tests/common.py new file mode 100644 index 0000000..a99e709 --- /dev/null +++ b/pystache/tests/common.py @@ -0,0 +1,193 @@ +# coding: utf-8 + +""" +Provides test-related code that can be used by all tests. + +""" + +import os + +import pystache +from pystache import defaults +from pystache.tests import examples + +# Save a reference to the original function to avoid recursion. +_DEFAULT_TAG_ESCAPE = defaults.TAG_ESCAPE +_TESTS_DIR = os.path.dirname(pystache.tests.__file__) + +DATA_DIR = os.path.join(_TESTS_DIR, 'data') # i.e. 'pystache/tests/data'. +EXAMPLES_DIR = os.path.dirname(examples.__file__) +PACKAGE_DIR = os.path.dirname(pystache.__file__) +PROJECT_DIR = os.path.join(PACKAGE_DIR, '..') +SPEC_TEST_DIR = os.path.join(PROJECT_DIR, 'ext', 'spec', 'specs') +# TEXT_DOCTEST_PATHS: the paths to text files (i.e. non-module files) +# containing doctests. The paths should be relative to the project directory. +TEXT_DOCTEST_PATHS = ['README.rst'] + +UNITTEST_FILE_PREFIX = "test_" + + +def html_escape(u): + """ + An html escape function that behaves the same in both Python 2 and 3. + + This function is needed because single quotes are escaped in Python 3 + (to '''), but not in Python 2. + + The global defaults.TAG_ESCAPE can be set to this function in the + setUp() and tearDown() of unittest test cases, for example, for + consistent test results. + + """ + u = _DEFAULT_TAG_ESCAPE(u) + return u.replace("'", ''') + + +def get_data_path(file_name): + return os.path.join(DATA_DIR, file_name) + + +# Functions related to get_module_names(). + +def _find_files(root_dir, should_include): + """ + Return a list of paths to all modules below the given directory. + + Arguments: + + should_include: a function that accepts a file path and returns True or False. + + """ + paths = [] # Return value. + + is_module = lambda path: path.endswith(".py") + + # os.walk() is new in Python 2.3 + # http://docs.python.org/library/os.html#os.walk + for dir_path, dir_names, file_names in os.walk(root_dir): + new_paths = [os.path.join(dir_path, file_name) for file_name in file_names] + new_paths = filter(is_module, new_paths) + new_paths = filter(should_include, new_paths) + paths.extend(new_paths) + + return paths + + +def _make_module_names(package_dir, paths): + """ + Return a list of fully-qualified module names given a list of module paths. + + """ + package_dir = os.path.abspath(package_dir) + package_name = os.path.split(package_dir)[1] + + prefix_length = len(package_dir) + + module_names = [] + for path in paths: + path = os.path.abspath(path) # for example <path_to_package>/subpackage/module.py + rel_path = path[prefix_length:] # for example /subpackage/module.py + rel_path = os.path.splitext(rel_path)[0] # for example /subpackage/module + + parts = [] + while True: + (rel_path, tail) = os.path.split(rel_path) + if not tail: + break + parts.insert(0, tail) + # We now have, for example, ['subpackage', 'module']. + parts.insert(0, package_name) + module = ".".join(parts) + module_names.append(module) + + return module_names + + +def get_module_names(package_dir=None, should_include=None): + """ + Return a list of fully-qualified module names in the given package. + + """ + if package_dir is None: + package_dir = PACKAGE_DIR + + if should_include is None: + should_include = lambda path: True + + paths = _find_files(package_dir, should_include) + names = _make_module_names(package_dir, paths) + names.sort() + + return names + + +class AssertStringMixin: + + """A unittest.TestCase mixin to check string equality.""" + + def assertString(self, actual, expected, format=None): + """ + Assert that the given strings are equal and have the same type. + + Arguments: + + format: a format string containing a single conversion specifier %s. + Defaults to "%s". + + """ + if format is None: + format = "%s" + + # Show both friendly and literal versions. + details = """String mismatch: %%s\ + + + Expected: \"""%s\""" + Actual: \"""%s\""" + + Expected: %s + Actual: %s""" % (expected, actual, repr(expected), repr(actual)) + + def make_message(reason): + description = details % reason + return format % description + + self.assertEqual(actual, expected, make_message("different characters")) + + reason = "types different: %s != %s (actual)" % (repr(type(expected)), repr(type(actual))) + self.assertEqual(type(expected), type(actual), make_message(reason)) + + +class AssertIsMixin: + + """A unittest.TestCase mixin adding assertIs().""" + + # unittest.assertIs() is not available until Python 2.7: + # http://docs.python.org/library/unittest.html#unittest.TestCase.assertIsNone + def assertIs(self, first, second): + self.assertTrue(first is second, msg="%s is not %s" % (repr(first), repr(second))) + + +class SetupDefaults(object): + + """ + Mix this class in to a unittest.TestCase for standard defaults. + + This class allows for consistent test results across Python 2/3. + + """ + + def setup_defaults(self): + self.original_decode_errors = defaults.DECODE_ERRORS + self.original_file_encoding = defaults.FILE_ENCODING + self.original_string_encoding = defaults.STRING_ENCODING + + defaults.DECODE_ERRORS = 'strict' + defaults.FILE_ENCODING = 'ascii' + defaults.STRING_ENCODING = 'ascii' + + def teardown_defaults(self): + defaults.DECODE_ERRORS = self.original_decode_errors + defaults.FILE_ENCODING = self.original_file_encoding + defaults.STRING_ENCODING = self.original_string_encoding + diff --git a/pystache/tests/data/__init__.py b/pystache/tests/data/__init__.py new file mode 100644 index 0000000..a0d386a --- /dev/null +++ b/pystache/tests/data/__init__.py @@ -0,0 +1,4 @@ +""" +TODO: add a docstring. + +""" diff --git a/pystache/tests/data/ascii.mustache b/pystache/tests/data/ascii.mustache new file mode 100644 index 0000000..e86737b --- /dev/null +++ b/pystache/tests/data/ascii.mustache @@ -0,0 +1 @@ +ascii: abc
\ No newline at end of file diff --git a/pystache/tests/data/duplicate.mustache b/pystache/tests/data/duplicate.mustache new file mode 100644 index 0000000..a0515e3 --- /dev/null +++ b/pystache/tests/data/duplicate.mustache @@ -0,0 +1 @@ +This file is used to test locate_path()'s search order.
\ No newline at end of file diff --git a/pystache/tests/data/locator/__init__.py b/pystache/tests/data/locator/__init__.py new file mode 100644 index 0000000..a0d386a --- /dev/null +++ b/pystache/tests/data/locator/__init__.py @@ -0,0 +1,4 @@ +""" +TODO: add a docstring. + +""" diff --git a/pystache/tests/data/locator/duplicate.mustache b/pystache/tests/data/locator/duplicate.mustache new file mode 100644 index 0000000..a0515e3 --- /dev/null +++ b/pystache/tests/data/locator/duplicate.mustache @@ -0,0 +1 @@ +This file is used to test locate_path()'s search order.
\ No newline at end of file diff --git a/pystache/tests/data/non_ascii.mustache b/pystache/tests/data/non_ascii.mustache new file mode 100644 index 0000000..bd69b61 --- /dev/null +++ b/pystache/tests/data/non_ascii.mustache @@ -0,0 +1 @@ +non-ascii: é
\ No newline at end of file diff --git a/pystache/tests/data/sample_view.mustache b/pystache/tests/data/sample_view.mustache new file mode 100644 index 0000000..e86737b --- /dev/null +++ b/pystache/tests/data/sample_view.mustache @@ -0,0 +1 @@ +ascii: abc
\ No newline at end of file diff --git a/pystache/tests/data/say_hello.mustache b/pystache/tests/data/say_hello.mustache new file mode 100644 index 0000000..84ab4c9 --- /dev/null +++ b/pystache/tests/data/say_hello.mustache @@ -0,0 +1 @@ +Hello, {{to}}
\ No newline at end of file diff --git a/pystache/tests/data/views.py b/pystache/tests/data/views.py new file mode 100644 index 0000000..0b96309 --- /dev/null +++ b/pystache/tests/data/views.py @@ -0,0 +1,21 @@ +# coding: utf-8 + +""" +TODO: add a docstring. + +""" + +from pystache import TemplateSpec + +class SayHello(object): + + def to(self): + return "World" + + +class SampleView(TemplateSpec): + pass + + +class NonAscii(TemplateSpec): + pass diff --git a/pystache/tests/doctesting.py b/pystache/tests/doctesting.py new file mode 100644 index 0000000..469c81e --- /dev/null +++ b/pystache/tests/doctesting.py @@ -0,0 +1,90 @@ +# coding: utf-8 + +""" +Exposes a get_doctests() function for the project's test harness. + +""" + +import doctest +import os +import pkgutil +import sys +import traceback + +if sys.version_info >= (3,): + # Then pull in modules needed for 2to3 conversion. The modules + # below are not necessarily available in older versions of Python. + from lib2to3.main import main as lib2to3main # new in Python 2.6? + from shutil import copyfile + +from pystache.tests.common import TEXT_DOCTEST_PATHS +from pystache.tests.common import get_module_names + + +# This module follows the guidance documented here: +# +# http://docs.python.org/library/doctest.html#unittest-api +# + +def get_doctests(text_file_dir): + """ + Return a list of TestSuite instances for all doctests in the project. + + Arguments: + + text_file_dir: the directory in which to search for all text files + (i.e. non-module files) containing doctests. + + """ + # Since module_relative is False in our calls to DocFileSuite below, + # paths should be OS-specific. See the following for more info-- + # + # http://docs.python.org/library/doctest.html#doctest.DocFileSuite + # + paths = [os.path.normpath(os.path.join(text_file_dir, path)) for path in TEXT_DOCTEST_PATHS] + + if sys.version_info >= (3,): + paths = _convert_paths(paths) + + suites = [] + + for path in paths: + suite = doctest.DocFileSuite(path, module_relative=False) + suites.append(suite) + + modules = get_module_names() + for module in modules: + suite = doctest.DocTestSuite(module) + suites.append(suite) + + return suites + + +def _convert_2to3(path): + """ + Convert the given file, and return the path to the converted files. + + """ + base, ext = os.path.splitext(path) + # For example, "README.temp2to3.rst". + new_path = "%s.temp2to3%s" % (base, ext) + + copyfile(path, new_path) + + args = ['--doctests_only', '--no-diffs', '--write', '--nobackups', new_path] + lib2to3main("lib2to3.fixes", args=args) + + return new_path + + +def _convert_paths(paths): + """ + Convert the given files, and return the paths to the converted files. + + """ + new_paths = [] + for path in paths: + new_path = _convert_2to3(path) + new_paths.append(new_path) + + return new_paths diff --git a/pystache/tests/examples/__init__.py b/pystache/tests/examples/__init__.py new file mode 100644 index 0000000..a0d386a --- /dev/null +++ b/pystache/tests/examples/__init__.py @@ -0,0 +1,4 @@ +""" +TODO: add a docstring. + +""" diff --git a/pystache/tests/examples/comments.mustache b/pystache/tests/examples/comments.mustache new file mode 100644 index 0000000..2a2a08b --- /dev/null +++ b/pystache/tests/examples/comments.mustache @@ -0,0 +1 @@ +<h1>{{title}}{{! just something interesting... #or not... }}</h1>
\ No newline at end of file diff --git a/pystache/tests/examples/comments.py b/pystache/tests/examples/comments.py new file mode 100644 index 0000000..8d75f88 --- /dev/null +++ b/pystache/tests/examples/comments.py @@ -0,0 +1,10 @@ + +""" +TODO: add a docstring. + +""" + +class Comments(object): + + def title(self): + return "A Comedy of Errors" diff --git a/pystache/tests/examples/complex.mustache b/pystache/tests/examples/complex.mustache new file mode 100644 index 0000000..6de758b --- /dev/null +++ b/pystache/tests/examples/complex.mustache @@ -0,0 +1,6 @@ +<h1>{{ header }}</h1> +{{#list}} +<ul> +{{#item}}{{# current }}<li><strong>{{name}}</strong></li> +{{/ current }}{{#link}}<li><a href="{{url}}">{{name}}</a></li> +{{/link}}{{/item}}</ul>{{/list}}{{#empty}}<p>The list is empty.</p>{{/empty}}
\ No newline at end of file diff --git a/pystache/tests/examples/complex.py b/pystache/tests/examples/complex.py new file mode 100644 index 0000000..c653db0 --- /dev/null +++ b/pystache/tests/examples/complex.py @@ -0,0 +1,26 @@ + +""" +TODO: add a docstring. + +""" + +class Complex(object): + + def header(self): + return "Colors" + + def item(self): + items = [] + items.append({ 'name': 'red', 'current': True, 'url': '#Red' }) + items.append({ 'name': 'green', 'link': True, 'url': '#Green' }) + items.append({ 'name': 'blue', 'link': True, 'url': '#Blue' }) + return items + + def list(self): + return not self.empty() + + def empty(self): + return len(self.item()) == 0 + + def empty_list(self): + return []; diff --git a/pystache/tests/examples/delimiters.mustache b/pystache/tests/examples/delimiters.mustache new file mode 100644 index 0000000..92bea6d --- /dev/null +++ b/pystache/tests/examples/delimiters.mustache @@ -0,0 +1,6 @@ +{{=<% %>=}} +* <% first %> +<%=| |=%> +* | second | +|={{ }}=| +* {{ third }} diff --git a/pystache/tests/examples/delimiters.py b/pystache/tests/examples/delimiters.py new file mode 100644 index 0000000..a31ec1b --- /dev/null +++ b/pystache/tests/examples/delimiters.py @@ -0,0 +1,16 @@ + +""" +TODO: add a docstring. + +""" + +class Delimiters(object): + + def first(self): + return "It worked the first time." + + def second(self): + return "And it worked the second time." + + def third(self): + return "Then, surprisingly, it worked the third time." diff --git a/pystache/tests/examples/double_section.mustache b/pystache/tests/examples/double_section.mustache new file mode 100644 index 0000000..61f1917 --- /dev/null +++ b/pystache/tests/examples/double_section.mustache @@ -0,0 +1,3 @@ +{{#t}}* first{{/t}} +* {{two}} +{{#t}}* third{{/t}}
\ No newline at end of file diff --git a/pystache/tests/examples/double_section.py b/pystache/tests/examples/double_section.py new file mode 100644 index 0000000..c9736e4 --- /dev/null +++ b/pystache/tests/examples/double_section.py @@ -0,0 +1,13 @@ + +""" +TODO: add a docstring. + +""" + +class DoubleSection(object): + + def t(self): + return True + + def two(self): + return "second" diff --git a/pystache/tests/examples/escaped.mustache b/pystache/tests/examples/escaped.mustache new file mode 100644 index 0000000..8be4ccb --- /dev/null +++ b/pystache/tests/examples/escaped.mustache @@ -0,0 +1 @@ +<h1>{{title}}</h1>
\ No newline at end of file diff --git a/pystache/tests/examples/escaped.py b/pystache/tests/examples/escaped.py new file mode 100644 index 0000000..5d72dde --- /dev/null +++ b/pystache/tests/examples/escaped.py @@ -0,0 +1,10 @@ + +""" +TODO: add a docstring. + +""" + +class Escaped(object): + + def title(self): + return "Bear > Shark" diff --git a/pystache/tests/examples/extensionless b/pystache/tests/examples/extensionless new file mode 100644 index 0000000..452c9fe --- /dev/null +++ b/pystache/tests/examples/extensionless @@ -0,0 +1 @@ +No file extension: {{foo}}
\ No newline at end of file diff --git a/pystache/tests/examples/inner_partial.mustache b/pystache/tests/examples/inner_partial.mustache new file mode 100644 index 0000000..2863764 --- /dev/null +++ b/pystache/tests/examples/inner_partial.mustache @@ -0,0 +1 @@ +Again, {{title}}!
\ No newline at end of file diff --git a/pystache/tests/examples/inner_partial.txt b/pystache/tests/examples/inner_partial.txt new file mode 100644 index 0000000..650c959 --- /dev/null +++ b/pystache/tests/examples/inner_partial.txt @@ -0,0 +1 @@ +## Again, {{title}}! ##
\ No newline at end of file diff --git a/pystache/tests/examples/inverted.mustache b/pystache/tests/examples/inverted.mustache new file mode 100644 index 0000000..fbea98d --- /dev/null +++ b/pystache/tests/examples/inverted.mustache @@ -0,0 +1 @@ +{{^f}}one{{/f}}, {{ two }}, {{^f}}three{{/f}}{{^t}}, four!{{/t}}{{^empty_list}}, empty list{{/empty_list}}{{^populated_list}}, shouldn't see me{{/populated_list}}
\ No newline at end of file diff --git a/pystache/tests/examples/inverted.py b/pystache/tests/examples/inverted.py new file mode 100644 index 0000000..12212b4 --- /dev/null +++ b/pystache/tests/examples/inverted.py @@ -0,0 +1,33 @@ + +""" +TODO: add a docstring. + +""" + +from pystache import TemplateSpec + +class Inverted(object): + + def t(self): + return True + + def f(self): + return False + + def two(self): + return 'two' + + def empty_list(self): + return [] + + def populated_list(self): + return ['some_value'] + +class InvertedLists(Inverted, TemplateSpec): + template_name = 'inverted' + + def t(self): + return [0, 1, 2] + + def f(self): + return [] diff --git a/pystache/tests/examples/lambdas.mustache b/pystache/tests/examples/lambdas.mustache new file mode 100644 index 0000000..9dffca5 --- /dev/null +++ b/pystache/tests/examples/lambdas.mustache @@ -0,0 +1 @@ +{{#replace_foo_with_bar}}foo != bar. oh, it does!{{/replace_foo_with_bar}}
\ No newline at end of file diff --git a/pystache/tests/examples/lambdas.py b/pystache/tests/examples/lambdas.py new file mode 100644 index 0000000..3bc08ff --- /dev/null +++ b/pystache/tests/examples/lambdas.py @@ -0,0 +1,38 @@ + +""" +TODO: add a docstring. + +""" + +from pystache import TemplateSpec + +def rot(s, n=13): + r = "" + for c in s: + cc = c + if cc.isalpha(): + cc = cc.lower() + o = ord(cc) + ro = (o+n) % 122 + if ro == 0: ro = 122 + if ro < 97: ro += 96 + cc = chr(ro) + r = ''.join((r,cc)) + return r + +def replace(subject, this='foo', with_this='bar'): + return subject.replace(this, with_this) + + +# This class subclasses TemplateSpec because at least one unit test +# sets the template attribute. +class Lambdas(TemplateSpec): + + def replace_foo_with_bar(self, text=None): + return replace + + def rot13(self, text=None): + return rot + + def sort(self, text=None): + return lambda text: ''.join(sorted(text)) diff --git a/pystache/tests/examples/looping_partial.mustache b/pystache/tests/examples/looping_partial.mustache new file mode 100644 index 0000000..577f736 --- /dev/null +++ b/pystache/tests/examples/looping_partial.mustache @@ -0,0 +1 @@ +Looping partial {{item}}!
\ No newline at end of file diff --git a/pystache/tests/examples/nested_context.mustache b/pystache/tests/examples/nested_context.mustache new file mode 100644 index 0000000..ce570d6 --- /dev/null +++ b/pystache/tests/examples/nested_context.mustache @@ -0,0 +1 @@ +{{#foo}}{{thing1}} and {{thing2}} and {{outer_thing}}{{/foo}}{{^foo}}Not foo!{{/foo}}
\ No newline at end of file diff --git a/pystache/tests/examples/nested_context.py b/pystache/tests/examples/nested_context.py new file mode 100644 index 0000000..a2661b9 --- /dev/null +++ b/pystache/tests/examples/nested_context.py @@ -0,0 +1,32 @@ + +""" +TODO: add a docstring. + +""" + +from pystache import TemplateSpec + +class NestedContext(TemplateSpec): + + def __init__(self, renderer): + self.renderer = renderer + + def _context_get(self, key): + return self.renderer.context.get(key) + + def outer_thing(self): + return "two" + + def foo(self): + return {'thing1': 'one', 'thing2': 'foo'} + + def derp(self): + return [{'inner': 'car'}] + + def herp(self): + return [{'outer': 'car'}] + + def nested_context_in_view(self): + if self._context_get('outer') == self._context_get('inner'): + return 'it works!' + return '' diff --git a/pystache/tests/examples/partial_in_partial.mustache b/pystache/tests/examples/partial_in_partial.mustache new file mode 100644 index 0000000..c61ceb1 --- /dev/null +++ b/pystache/tests/examples/partial_in_partial.mustache @@ -0,0 +1 @@ +{{>simple}}
\ No newline at end of file diff --git a/pystache/tests/examples/partial_with_lambda.mustache b/pystache/tests/examples/partial_with_lambda.mustache new file mode 100644 index 0000000..2989f56 --- /dev/null +++ b/pystache/tests/examples/partial_with_lambda.mustache @@ -0,0 +1 @@ +{{#rot13}}abcdefghijklm{{/rot13}}
\ No newline at end of file diff --git a/pystache/tests/examples/partial_with_partial_and_lambda.mustache b/pystache/tests/examples/partial_with_partial_and_lambda.mustache new file mode 100644 index 0000000..0729e10 --- /dev/null +++ b/pystache/tests/examples/partial_with_partial_and_lambda.mustache @@ -0,0 +1 @@ +{{>partial_with_lambda}}{{#rot13}}abcdefghijklm{{/rot13}}
\ No newline at end of file diff --git a/pystache/tests/examples/partials_with_lambdas.py b/pystache/tests/examples/partials_with_lambdas.py new file mode 100644 index 0000000..638aa36 --- /dev/null +++ b/pystache/tests/examples/partials_with_lambdas.py @@ -0,0 +1,12 @@ + +""" +TODO: add a docstring. + +""" + +from pystache.tests.examples.lambdas import rot + +class PartialsWithLambdas(object): + + def rot(self): + return rot diff --git a/pystache/tests/examples/readme.py b/pystache/tests/examples/readme.py new file mode 100644 index 0000000..8dcee43 --- /dev/null +++ b/pystache/tests/examples/readme.py @@ -0,0 +1,9 @@ + +""" +TODO: add a docstring. + +""" + +class SayHello(object): + def to(self): + return "Pizza" diff --git a/pystache/tests/examples/say_hello.mustache b/pystache/tests/examples/say_hello.mustache new file mode 100644 index 0000000..7d8dfea --- /dev/null +++ b/pystache/tests/examples/say_hello.mustache @@ -0,0 +1 @@ +Hello, {{to}}!
\ No newline at end of file diff --git a/pystache/tests/examples/simple.mustache b/pystache/tests/examples/simple.mustache new file mode 100644 index 0000000..9214dab --- /dev/null +++ b/pystache/tests/examples/simple.mustache @@ -0,0 +1 @@ +Hi {{thing}}!{{blank}}
\ No newline at end of file diff --git a/pystache/tests/examples/simple.py b/pystache/tests/examples/simple.py new file mode 100644 index 0000000..ea82e9d --- /dev/null +++ b/pystache/tests/examples/simple.py @@ -0,0 +1,15 @@ + +""" +TODO: add a docstring. + +""" + +from pystache import TemplateSpec + +class Simple(TemplateSpec): + + def thing(self): + return "pizza" + + def blank(self): + return '' diff --git a/pystache/tests/examples/tagless.mustache b/pystache/tests/examples/tagless.mustache new file mode 100644 index 0000000..ad4dd31 --- /dev/null +++ b/pystache/tests/examples/tagless.mustache @@ -0,0 +1 @@ +No tags...
\ No newline at end of file diff --git a/pystache/tests/examples/template_partial.mustache b/pystache/tests/examples/template_partial.mustache new file mode 100644 index 0000000..03f76cf --- /dev/null +++ b/pystache/tests/examples/template_partial.mustache @@ -0,0 +1,2 @@ +<h1>{{title}}</h1> +{{>inner_partial}}
\ No newline at end of file diff --git a/pystache/tests/examples/template_partial.py b/pystache/tests/examples/template_partial.py new file mode 100644 index 0000000..1c4d1a0 --- /dev/null +++ b/pystache/tests/examples/template_partial.py @@ -0,0 +1,27 @@ + +""" +TODO: add a docstring. + +""" + +from pystache import TemplateSpec + +class TemplatePartial(TemplateSpec): + + def __init__(self, renderer): + self.renderer = renderer + + def _context_get(self, key): + return self.renderer.context.get(key) + + def title(self): + return "Welcome" + + def title_bars(self): + return '-' * len(self.title()) + + def looping(self): + return [{'item': 'one'}, {'item': 'two'}, {'item': 'three'}] + + def thing(self): + return self._context_get('prop') diff --git a/pystache/tests/examples/template_partial.txt b/pystache/tests/examples/template_partial.txt new file mode 100644 index 0000000..d9b5f6e --- /dev/null +++ b/pystache/tests/examples/template_partial.txt @@ -0,0 +1,4 @@ +{{title}} +{{title_bars}} + +{{>inner_partial}} diff --git a/pystache/tests/examples/unescaped.mustache b/pystache/tests/examples/unescaped.mustache new file mode 100644 index 0000000..9982708 --- /dev/null +++ b/pystache/tests/examples/unescaped.mustache @@ -0,0 +1 @@ +<h1>{{{title}}}</h1>
\ No newline at end of file diff --git a/pystache/tests/examples/unescaped.py b/pystache/tests/examples/unescaped.py new file mode 100644 index 0000000..92889af --- /dev/null +++ b/pystache/tests/examples/unescaped.py @@ -0,0 +1,10 @@ + +""" +TODO: add a docstring. + +""" + +class Unescaped(object): + + def title(self): + return "Bear > Shark" diff --git a/pystache/tests/examples/unicode_input.mustache b/pystache/tests/examples/unicode_input.mustache new file mode 100644 index 0000000..f654cd1 --- /dev/null +++ b/pystache/tests/examples/unicode_input.mustache @@ -0,0 +1 @@ +abcdé
\ No newline at end of file diff --git a/pystache/tests/examples/unicode_input.py b/pystache/tests/examples/unicode_input.py new file mode 100644 index 0000000..d045757 --- /dev/null +++ b/pystache/tests/examples/unicode_input.py @@ -0,0 +1,14 @@ + +""" +TODO: add a docstring. + +""" + +from pystache import TemplateSpec + +class UnicodeInput(TemplateSpec): + + template_encoding = 'utf8' + + def age(self): + return 156 diff --git a/pystache/tests/examples/unicode_output.mustache b/pystache/tests/examples/unicode_output.mustache new file mode 100644 index 0000000..8495f56 --- /dev/null +++ b/pystache/tests/examples/unicode_output.mustache @@ -0,0 +1 @@ +<p>Name: {{name}}</p>
\ No newline at end of file diff --git a/pystache/tests/examples/unicode_output.py b/pystache/tests/examples/unicode_output.py new file mode 100644 index 0000000..da0e1d2 --- /dev/null +++ b/pystache/tests/examples/unicode_output.py @@ -0,0 +1,11 @@ +# encoding: utf-8 + +""" +TODO: add a docstring. + +""" + +class UnicodeOutput(object): + + def name(self): + return u'Henri Poincaré' diff --git a/pystache/tests/main.py b/pystache/tests/main.py new file mode 100644 index 0000000..de56c44 --- /dev/null +++ b/pystache/tests/main.py @@ -0,0 +1,155 @@ +# coding: utf-8 + +""" +Exposes a run_tests() function that runs all tests in the project. + +This module is for our test console script. + +""" + +import os +import sys +import unittest +from unittest import TestProgram + +import pystache +from pystache.tests.common import PACKAGE_DIR, PROJECT_DIR, SPEC_TEST_DIR, UNITTEST_FILE_PREFIX +from pystache.tests.common import get_module_names +from pystache.tests.doctesting import get_doctests +from pystache.tests.spectesting import get_spec_tests + + +# If this command option is present, then the spec test and doctest directories +# will be inserted if not provided. +FROM_SOURCE_OPTION = "--from-source" + + +# 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. + + Arguments: + + sys_argv: a reference to sys.argv. + + """ + should_source_exist = False + spec_test_dir = None + project_dir = None + + if len(sys_argv) > 1 and sys_argv[1] == FROM_SOURCE_OPTION: + should_source_exist = True + sys_argv.pop(1) + + # TODO: use logging module + print "pystache: running tests: expecting source: %s" % should_source_exist + + try: + # TODO: use optparse command options instead. + spec_test_dir = sys_argv[1] + sys_argv.pop(1) + except IndexError: + if should_source_exist: + spec_test_dir = SPEC_TEST_DIR + + try: + # TODO: use optparse command options instead. + project_dir = sys_argv[1] + sys_argv.pop(1) + except IndexError: + if should_source_exist: + project_dir = PROJECT_DIR + + if len(sys_argv) <= 1 or sys_argv[-1].startswith("-"): + # Then no explicit module or test names were provided, so + # auto-detect all unit tests. + module_names = _discover_test_modules(PACKAGE_DIR) + sys_argv.extend(module_names) + if project_dir is not None: + # Add the current module for unit tests contained here. + sys_argv.append(__name__) + + _PystacheTestProgram._text_doctest_dir = project_dir + _PystacheTestProgram._spec_test_dir = spec_test_dir + SetupTests.project_dir = project_dir + + # We pass None for the module because we do not want the unittest + # module to resolve module names relative to a given module. + # (This would require importing all of the unittest modules from + # this module.) See the loadTestsFromName() method of the + # unittest.TestLoader class for more details on this parameter. + _PystacheTestProgram(argv=sys_argv, module=None) + # No need to return since unitttest.main() exits. + + +def _discover_test_modules(package_dir): + """ + Discover and return a sorted list of the names of unit-test modules. + + """ + def is_unittest_module(path): + file_name = os.path.basename(path) + return file_name.startswith(UNITTEST_FILE_PREFIX) + + names = get_module_names(package_dir=package_dir, should_include=is_unittest_module) + + # This is a sanity check to ensure that the unit-test discovery + # methods are working. + if len(names) < 1: + raise Exception("No unit-test modules found--\n in %s" % package_dir) + + return names + + +class SetupTests(unittest.TestCase): + + """Tests about setup.py.""" + + project_dir = None + + def test_version(self): + """ + Test that setup.py's version matches the package's version. + + """ + original_path = list(sys.path) + + sys.path.insert(0, self.project_dir) + + try: + from setup import VERSION + self.assertEqual(VERSION, pystache.__version__) + finally: + sys.path = original_path + + +# The function unittest.main() is an alias for unittest.TestProgram's +# constructor. TestProgram's constructor calls self.runTests() as its +# final step, which expects self.test to be set. The constructor sets +# the self.test attribute by calling one of self.testLoader's "loadTests" +# methods prior to callint self.runTests(). Each loadTest method returns +# a unittest.TestSuite instance. Thus, self.test is set to a TestSuite +# instance prior to calling runTests(). +class _PystacheTestProgram(TestProgram): + + """ + Instantiating an instance of this class runs all tests. + + """ + + def runTests(self): + # self.test is a unittest.TestSuite instance: + # http://docs.python.org/library/unittest.html#unittest.TestSuite + tests = self.test + + if self._text_doctest_dir is not None: + doctest_suites = get_doctests(self._text_doctest_dir) + tests.addTests(doctest_suites) + + if self._spec_test_dir is not None: + spec_testcases = get_spec_tests(self._spec_test_dir) + tests.addTests(spec_testcases) + + TestProgram.runTests(self) diff --git a/pystache/tests/spectesting.py b/pystache/tests/spectesting.py new file mode 100644 index 0000000..ec8a08d --- /dev/null +++ b/pystache/tests/spectesting.py @@ -0,0 +1,285 @@ +# coding: utf-8 + +""" +Exposes a get_spec_tests() function for the project's test harness. + +Creates a unittest.TestCase for the tests defined in the mustache spec. + +""" + +# TODO: this module can be cleaned up somewhat. +# TODO: move all of this code to pystache/tests/spectesting.py and +# have it expose a get_spec_tests(spec_test_dir) function. + +FILE_ENCODING = 'utf-8' # the encoding of the spec test files. + +yaml = None + +try: + # We try yaml first since it is more convenient when adding and modifying + # test cases by hand (since the YAML is human-readable and is the master + # from which the JSON format is generated). + import yaml +except ImportError: + try: + import json + except: + # The module json is not available prior to Python 2.6, whereas + # simplejson is. The simplejson package dropped support for Python 2.4 + # in simplejson v2.1.0, so Python 2.4 requires a simplejson install + # older than the most recent version. + try: + import simplejson as json + except ImportError: + # Raise an error with a type different from ImportError as a hack around + # this issue: + # http://bugs.python.org/issue7559 + from sys import exc_info + ex_type, ex_value, tb = exc_info() + new_ex = Exception("%s: %s" % (ex_type.__name__, ex_value)) + raise new_ex.__class__, new_ex, tb + file_extension = 'json' + parser = json +else: + file_extension = 'yml' + parser = yaml + + +import codecs +import glob +import os.path +import unittest + +import pystache +from pystache import common +from pystache.renderer import Renderer +from pystache.tests.common import AssertStringMixin + + +def get_spec_tests(spec_test_dir): + """ + Return a list of unittest.TestCase instances. + + """ + # TODO: use logging module instead. + print "pystache: spec tests: using %s" % _get_parser_info() + + cases = [] + + # Make this absolute for easier diagnosis in case of error. + spec_test_dir = os.path.abspath(spec_test_dir) + spec_paths = glob.glob(os.path.join(spec_test_dir, '*.%s' % file_extension)) + + for path in spec_paths: + new_cases = _read_spec_tests(path) + cases.extend(new_cases) + + # Store this as a value so that CheckSpecTestsFound is not checking + # a reference to cases that contains itself. + spec_test_count = len(cases) + + # This test case lets us alert the user that spec tests are missing. + class CheckSpecTestsFound(unittest.TestCase): + + def runTest(self): + if spec_test_count > 0: + return + raise Exception("Spec tests not found--\n in %s\n" + " Consult the README file on how to add the Mustache spec tests." % repr(spec_test_dir)) + + case = CheckSpecTestsFound() + cases.append(case) + + return cases + + +def _get_parser_info(): + return "%s (version %s)" % (parser.__name__, parser.__version__) + + +def _read_spec_tests(path): + """ + Return a list of unittest.TestCase instances. + + """ + b = common.read(path) + u = unicode(b, encoding=FILE_ENCODING) + spec_data = parse(u) + tests = spec_data['tests'] + + cases = [] + for data in tests: + case = _deserialize_spec_test(data, path) + cases.append(case) + + 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. + + Arguments: + + data: the dictionary of attributes for a single test. + + """ + context = data['data'] + description = data['desc'] + # PyYAML seems to leave ASCII strings as byte strings. + expected = unicode(data['expected']) + # TODO: switch to using dict.get(). + partials = data.has_key('partials') and data['partials'] or {} + template = data['template'] + test_name = data['name'] + + _convert_children(context) + + test_case = _make_spec_test(expected, template, context, partials, description, test_name, file_path) + + return test_case + + +def _make_spec_test(expected, template, context, partials, description, test_name, file_path): + """ + Return a unittest.TestCase instance representing a spec test. + + """ + file_name = os.path.basename(file_path) + test_method_name = "Mustache spec (%s): %s" % (file_name, repr(test_name)) + + # We subclass SpecTestBase in order to control the test method name (for + # the purposes of improved reporting). + class SpecTest(SpecTestBase): + pass + + def run_test(self): + self._runTest() + + # TODO: should we restore this logic somewhere? + # If we don't convert unicode to str, we get the following error: + # "TypeError: __name__ must be set to a string object" + # test.__name__ = str(name) + setattr(SpecTest, test_method_name, run_test) + case = SpecTest(test_method_name) + + case._context = context + case._description = description + case._expected = expected + case._file_path = file_path + case._partials = partials + case._template = template + case._test_name = test_name + + return case + + +def parse(u): + """ + Parse the contents of a spec test file, and return a dict. + + Arguments: + + u: a unicode string. + + """ + # TODO: find a cleaner mechanism for choosing between the two. + if yaml is None: + # Then use json. + + # The only way to get the simplejson module to return unicode strings + # is to pass it unicode. See, for example-- + # + # http://code.google.com/p/simplejson/issues/detail?id=40 + # + # and the documentation of simplejson.loads(): + # + # "If s is a str then decoded JSON strings that contain only ASCII + # characters may be parsed as str for performance and memory reasons. + # If your code expects only unicode the appropriate solution is + # decode s to unicode prior to calling loads." + # + return json.loads(u) + # Otherwise, yaml. + + def code_constructor(loader, node): + value = loader.construct_mapping(node) + return eval(value['python'], {}) + + yaml.add_constructor(u'!code', code_constructor) + return yaml.load(u) + + +class SpecTestBase(unittest.TestCase, AssertStringMixin): + + def _runTest(self): + context = self._context + description = self._description + expected = self._expected + file_path = self._file_path + partials = self._partials + template = self._template + test_name = self._test_name + + renderer = Renderer(partials=partials) + actual = renderer.render(template, context) + + # We need to escape the strings that occur in our format string because + # they can contain % symbols, for example (in delimiters.yml)-- + # + # "template: '{{=<% %>=}}(<%text%>)'" + # + def escape(s): + return s.replace("%", "%%") + + parser_info = _get_parser_info() + subs = [repr(test_name), description, os.path.abspath(file_path), + template, repr(context), parser_info] + subs = tuple([escape(sub) for sub in subs]) + # We include the parsing module version info to help with troubleshooting + # yaml/json/simplejson issues. + message = """%s: %s + + File: %s + + Template: \"""%s\""" + + Context: %s + + %%s + + [using %s] + """ % subs + + self.assertString(actual, expected, format=message) diff --git a/pystache/tests/test___init__.py b/pystache/tests/test___init__.py new file mode 100644 index 0000000..d4f3526 --- /dev/null +++ b/pystache/tests/test___init__.py @@ -0,0 +1,36 @@ +# coding: utf-8 + +""" +Tests of __init__.py. + +""" + +# Calling "import *" is allowed only at the module level. +GLOBALS_INITIAL = globals().keys() +from pystache import * +GLOBALS_PYSTACHE_IMPORTED = globals().keys() + +import unittest + +import pystache + + +class InitTests(unittest.TestCase): + + def test___all__(self): + """ + Test that "from pystache import *" works as expected. + + """ + actual = set(GLOBALS_PYSTACHE_IMPORTED) - set(GLOBALS_INITIAL) + expected = set(['render', 'Renderer', 'TemplateSpec', 'GLOBALS_INITIAL']) + + self.assertEqual(actual, expected) + + def test_version_defined(self): + """ + Test that pystache.__version__ is set. + + """ + actual_version = pystache.__version__ + self.assertTrue(actual_version) diff --git a/pystache/tests/test_commands.py b/pystache/tests/test_commands.py new file mode 100644 index 0000000..2529d25 --- /dev/null +++ b/pystache/tests/test_commands.py @@ -0,0 +1,45 @@ +# coding: utf-8 + +""" +Unit tests of commands.py. + +""" + +import sys +import unittest + +from pystache.commands.render import main + + +ORIGINAL_STDOUT = sys.stdout + + +class MockStdout(object): + + def __init__(self): + self.output = "" + + def write(self, str): + self.output += str + + +class CommandsTestCase(unittest.TestCase): + + def setUp(self): + sys.stdout = MockStdout() + + def callScript(self, template, context): + argv = ['pystache', template, context] + main(argv) + return sys.stdout.output + + def testMainSimple(self): + """ + Test a simple command-line case. + + """ + actual = self.callScript("Hi {{thing}}", '{"thing": "world"}') + self.assertEqual(actual, u"Hi world\n") + + def tearDown(self): + sys.stdout = ORIGINAL_STDOUT diff --git a/pystache/tests/test_context.py b/pystache/tests/test_context.py new file mode 100644 index 0000000..03377f7 --- /dev/null +++ b/pystache/tests/test_context.py @@ -0,0 +1,444 @@ +# coding: utf-8 + +""" +Unit tests of context.py. + +""" + +from datetime import datetime +import unittest + +from pystache.context import _NOT_FOUND +from pystache.context import _get_value +from pystache.context import ContextStack +from pystache.tests.common import AssertIsMixin, Attachable + +class SimpleObject(object): + + """A sample class that does not define __getitem__().""" + + def __init__(self): + self.foo = "bar" + + def foo_callable(self): + return "called..." + + +class DictLike(object): + + """A sample class that implements __getitem__() and __contains__().""" + + def __init__(self): + self._dict = {'foo': 'bar'} + self.fuzz = 'buzz' + + def __contains__(self, key): + return key in self._dict + + def __getitem__(self, key): + return self._dict[key] + + +class GetValueTests(unittest.TestCase, AssertIsMixin): + + """Test context._get_value().""" + + def assertNotFound(self, item, key): + """ + Assert that a call to _get_value() returns _NOT_FOUND. + + """ + self.assertIs(_get_value(item, key), _NOT_FOUND) + + ### Case: the item is a dictionary. + + def test_dictionary__key_present(self): + """ + Test getting a key from a dictionary. + + """ + item = {"foo": "bar"} + self.assertEqual(_get_value(item, "foo"), "bar") + + def test_dictionary__callable_not_called(self): + """ + Test that callable values are returned as-is (and in particular not called). + + """ + def foo_callable(self): + return "bar" + + item = {"foo": foo_callable} + self.assertNotEqual(_get_value(item, "foo"), "bar") + self.assertTrue(_get_value(item, "foo") is foo_callable) + + def test_dictionary__key_missing(self): + """ + Test getting a missing key from a dictionary. + + """ + item = {} + self.assertNotFound(item, "missing") + + def test_dictionary__attributes_not_checked(self): + """ + Test that dictionary attributes are not checked. + + """ + item = {1: 2, 3: 4} + # I was not able to find a "public" attribute of dict that is + # the same across Python 2/3. + attr_name = "__len__" + self.assertEqual(getattr(item, attr_name)(), 2) + self.assertNotFound(item, attr_name) + + def test_dictionary__dict_subclass(self): + """ + Test that subclasses of dict are treated as dictionaries. + + """ + class DictSubclass(dict): pass + + item = DictSubclass() + item["foo"] = "bar" + + self.assertEqual(_get_value(item, "foo"), "bar") + + ### Case: the item is an object. + + def test_object__attribute_present(self): + """ + Test getting an attribute from an object. + + """ + item = SimpleObject() + self.assertEqual(_get_value(item, "foo"), "bar") + + def test_object__attribute_missing(self): + """ + Test getting a missing attribute from an object. + + """ + item = SimpleObject() + self.assertNotFound(item, "missing") + + def test_object__attribute_is_callable(self): + """ + Test getting a callable attribute from an object. + + """ + item = SimpleObject() + self.assertEqual(_get_value(item, "foo_callable"), "called...") + + def test_object__non_built_in_type(self): + """ + Test getting an attribute from an instance of a type that isn't built-in. + + """ + item = datetime(2012, 1, 2) + self.assertEqual(_get_value(item, "day"), 2) + + def test_object__dict_like(self): + """ + Test getting a key from a dict-like object (an object that implements '__getitem__'). + + """ + item = DictLike() + self.assertEqual(item["foo"], "bar") + self.assertNotFound(item, "foo") + + ### Case: the item is an instance of a built-in type. + + def test_built_in_type__integer(self): + """ + Test getting from an integer. + + """ + class MyInt(int): pass + + cust_int = MyInt(10) + pure_int = 10 + + # We have to use a built-in method like __neg__ because "public" + # attributes like "real" were not added to Python until Python 2.6, + # when the numeric type hierarchy was added: + # + # http://docs.python.org/library/numbers.html + # + self.assertEqual(cust_int.__neg__(), -10) + self.assertEqual(pure_int.__neg__(), -10) + + self.assertEqual(_get_value(cust_int, '__neg__'), -10) + self.assertNotFound(pure_int, '__neg__') + + def test_built_in_type__string(self): + """ + Test getting from a string. + + """ + class MyStr(str): pass + + item1 = MyStr('abc') + item2 = 'abc' + + self.assertEqual(item1.upper(), 'ABC') + self.assertEqual(item2.upper(), 'ABC') + + self.assertEqual(_get_value(item1, 'upper'), 'ABC') + self.assertNotFound(item2, 'upper') + + def test_built_in_type__list(self): + """ + Test getting from a list. + + """ + class MyList(list): pass + + item1 = MyList([1, 2, 3]) + item2 = [1, 2, 3] + + self.assertEqual(item1.pop(), 3) + self.assertEqual(item2.pop(), 3) + + self.assertEqual(_get_value(item1, 'pop'), 2) + self.assertNotFound(item2, 'pop') + + +class ContextStackTests(unittest.TestCase, AssertIsMixin): + + """ + Test the ContextStack class. + + """ + + def test_init__no_elements(self): + """ + Check that passing nothing to __init__() raises no exception. + + """ + context = ContextStack() + + def test_init__many_elements(self): + """ + Check that passing more than two items to __init__() raises no exception. + + """ + context = ContextStack({}, {}, {}) + + def test__repr(self): + context = ContextStack() + self.assertEqual(repr(context), 'ContextStack()') + + context = ContextStack({'foo': 'bar'}) + self.assertEqual(repr(context), "ContextStack({'foo': 'bar'},)") + + context = ContextStack({'foo': 'bar'}, {'abc': 123}) + self.assertEqual(repr(context), "ContextStack({'foo': 'bar'}, {'abc': 123})") + + def test__str(self): + context = ContextStack() + self.assertEqual(str(context), 'ContextStack()') + + context = ContextStack({'foo': 'bar'}) + self.assertEqual(str(context), "ContextStack({'foo': 'bar'},)") + + context = ContextStack({'foo': 'bar'}, {'abc': 123}) + self.assertEqual(str(context), "ContextStack({'foo': 'bar'}, {'abc': 123})") + + ## Test the static create() method. + + def test_create__dictionary(self): + """ + Test passing a dictionary. + + """ + context = ContextStack.create({'foo': 'bar'}) + self.assertEqual(context.get('foo'), 'bar') + + def test_create__none(self): + """ + Test passing None. + + """ + context = ContextStack.create({'foo': 'bar'}, None) + self.assertEqual(context.get('foo'), 'bar') + + def test_create__object(self): + """ + Test passing an object. + + """ + class Foo(object): + foo = 'bar' + context = ContextStack.create(Foo()) + self.assertEqual(context.get('foo'), 'bar') + + def test_create__context(self): + """ + Test passing a ContextStack instance. + + """ + obj = ContextStack({'foo': 'bar'}) + context = ContextStack.create(obj) + self.assertEqual(context.get('foo'), 'bar') + + def test_create__kwarg(self): + """ + Test passing a keyword argument. + + """ + context = ContextStack.create(foo='bar') + self.assertEqual(context.get('foo'), 'bar') + + def test_create__precedence_positional(self): + """ + Test precedence of positional arguments. + + """ + context = ContextStack.create({'foo': 'bar'}, {'foo': 'buzz'}) + self.assertEqual(context.get('foo'), 'buzz') + + def test_create__precedence_keyword(self): + """ + Test precedence of keyword arguments. + + """ + context = ContextStack.create({'foo': 'bar'}, foo='buzz') + self.assertEqual(context.get('foo'), 'buzz') + + def test_get__key_present(self): + """ + Test getting a key. + + """ + context = ContextStack({"foo": "bar"}) + self.assertEqual(context.get("foo"), "bar") + + def test_get__key_missing(self): + """ + Test getting a missing key. + + """ + context = ContextStack() + self.assertTrue(context.get("foo") is None) + + def test_get__default(self): + """ + Test that get() respects the default value. + + """ + context = ContextStack() + self.assertEqual(context.get("foo", "bar"), "bar") + + def test_get__precedence(self): + """ + Test that get() respects the order of precedence (later items first). + + """ + context = ContextStack({"foo": "bar"}, {"foo": "buzz"}) + self.assertEqual(context.get("foo"), "buzz") + + def test_get__fallback(self): + """ + Check that first-added stack items are queried on context misses. + + """ + context = ContextStack({"fuzz": "buzz"}, {"foo": "bar"}) + self.assertEqual(context.get("fuzz"), "buzz") + + def test_push(self): + """ + Test push(). + + """ + key = "foo" + context = ContextStack({key: "bar"}) + self.assertEqual(context.get(key), "bar") + + context.push({key: "buzz"}) + self.assertEqual(context.get(key), "buzz") + + def test_pop(self): + """ + Test pop(). + + """ + key = "foo" + context = ContextStack({key: "bar"}, {key: "buzz"}) + self.assertEqual(context.get(key), "buzz") + + item = context.pop() + self.assertEqual(item, {"foo": "buzz"}) + self.assertEqual(context.get(key), "bar") + + def test_top(self): + key = "foo" + context = ContextStack({key: "bar"}, {key: "buzz"}) + self.assertEqual(context.get(key), "buzz") + + top = context.top() + self.assertEqual(top, {"foo": "buzz"}) + # Make sure calling top() didn't remove the item from the stack. + self.assertEqual(context.get(key), "buzz") + + def test_copy(self): + key = "foo" + original = ContextStack({key: "bar"}, {key: "buzz"}) + self.assertEqual(original.get(key), "buzz") + + new = original.copy() + # Confirm that the copy behaves the same. + self.assertEqual(new.get(key), "buzz") + # Change the copy, and confirm it is changed. + new.pop() + self.assertEqual(new.get(key), "bar") + # Confirm the original is unchanged. + self.assertEqual(original.get(key), "buzz") + + def test_dot_notation__dict(self): + key = "foo.bar" + original = ContextStack({"foo": {"bar": "baz"}}) + self.assertEquals(original.get(key), "baz") + + # Works all the way down + key = "a.b.c.d.e.f.g" + original = ContextStack({"a": {"b": {"c": {"d": {"e": {"f": {"g": "w00t!"}}}}}}}) + self.assertEquals(original.get(key), "w00t!") + + def test_dot_notation__user_object(self): + key = "foo.bar" + original = ContextStack({"foo": Attachable(bar="baz")}) + self.assertEquals(original.get(key), "baz") + + # Works on multiple levels, too + key = "a.b.c.d.e.f.g" + Obj = Attachable + original = ContextStack({"a": Obj(b=Obj(c=Obj(d=Obj(e=Obj(f=Obj(g="w00t!"))))))}) + self.assertEquals(original.get(key), "w00t!") + + def test_dot_notation__mixed_dict_and_obj(self): + key = "foo.bar.baz.bak" + original = ContextStack({"foo": Attachable(bar={"baz": Attachable(bak=42)})}) + self.assertEquals(original.get(key), 42) + + def test_dot_notation__missing_attr_or_key(self): + key = "foo.bar.baz.bak" + original = ContextStack({"foo": {"bar": {}}}) + self.assertEquals(original.get(key), None) + + original = ContextStack({"foo": Attachable(bar=Attachable())}) + self.assertEquals(original.get(key), None) + + def test_dot_notattion__autocall(self): + key = "foo.bar.baz" + + # When any element in the path is callable, it should be automatically invoked + original = ContextStack({"foo": Attachable(bar=Attachable(baz=lambda: "Called!"))}) + self.assertEquals(original.get(key), "Called!") + + class Foo(object): + def bar(self): + return Attachable(baz='Baz') + + original = ContextStack({"foo": Foo()}) + self.assertEquals(original.get(key), "Baz") diff --git a/pystache/tests/test_examples.py b/pystache/tests/test_examples.py new file mode 100644 index 0000000..5c9f74d --- /dev/null +++ b/pystache/tests/test_examples.py @@ -0,0 +1,106 @@ +# encoding: utf-8 + +""" +TODO: add a docstring. + +""" + +import unittest + +from examples.comments import Comments +from examples.double_section import DoubleSection +from examples.escaped import Escaped +from examples.unescaped import Unescaped +from examples.template_partial import TemplatePartial +from examples.delimiters import Delimiters +from examples.unicode_output import UnicodeOutput +from examples.unicode_input import UnicodeInput +from examples.nested_context import NestedContext +from pystache import Renderer +from pystache.tests.common import EXAMPLES_DIR +from pystache.tests.common import AssertStringMixin + + +class TestView(unittest.TestCase, AssertStringMixin): + + def _assert(self, obj, expected): + renderer = Renderer() + actual = renderer.render(obj) + self.assertString(actual, expected) + + def test_comments(self): + self._assert(Comments(), u"<h1>A Comedy of Errors</h1>") + + def test_double_section(self): + self._assert(DoubleSection(), u"* first\n* second\n* third") + + def test_unicode_output(self): + renderer = Renderer() + actual = renderer.render(UnicodeOutput()) + self.assertString(actual, u'<p>Name: Henri Poincaré</p>') + + def test_unicode_input(self): + renderer = Renderer() + actual = renderer.render(UnicodeInput()) + self.assertString(actual, u'abcdé') + + def test_escaping(self): + self._assert(Escaped(), u"<h1>Bear > Shark</h1>") + + def test_literal(self): + renderer = Renderer() + actual = renderer.render(Unescaped()) + self.assertString(actual, u"<h1>Bear > Shark</h1>") + + def test_template_partial(self): + renderer = Renderer(search_dirs=EXAMPLES_DIR) + actual = renderer.render(TemplatePartial(renderer=renderer)) + + self.assertString(actual, u"""<h1>Welcome</h1> +Again, Welcome!""") + + def test_template_partial_extension(self): + renderer = Renderer(search_dirs=EXAMPLES_DIR, file_extension='txt') + + view = TemplatePartial(renderer=renderer) + + actual = renderer.render(view) + self.assertString(actual, u"""Welcome +------- + +## Again, Welcome! ##""") + + def test_delimiters(self): + renderer = Renderer() + actual = renderer.render(Delimiters()) + self.assertString(actual, u"""\ +* It worked the first time. +* And it worked the second time. +* Then, surprisingly, it worked the third time. +""") + + def test_nested_context(self): + renderer = Renderer() + actual = renderer.render(NestedContext(renderer)) + self.assertString(actual, u"one and foo and two") + + def test_nested_context_is_available_in_view(self): + renderer = Renderer() + + view = NestedContext(renderer) + view.template = '{{#herp}}{{#derp}}{{nested_context_in_view}}{{/derp}}{{/herp}}' + + actual = renderer.render(view) + self.assertString(actual, u'it works!') + + def test_partial_in_partial_has_access_to_grand_parent_context(self): + renderer = Renderer(search_dirs=EXAMPLES_DIR) + + view = TemplatePartial(renderer=renderer) + view.template = '''{{>partial_in_partial}}''' + + actual = renderer.render(view, {'prop': 'derp'}) + self.assertEqual(actual, 'Hi derp!') + +if __name__ == '__main__': + unittest.main() diff --git a/pystache/tests/test_loader.py b/pystache/tests/test_loader.py new file mode 100644 index 0000000..c47239c --- /dev/null +++ b/pystache/tests/test_loader.py @@ -0,0 +1,194 @@ +# encoding: utf-8 + +""" +Unit tests of loader.py. + +""" + +import os +import sys +import unittest + +from pystache.tests.common import AssertStringMixin, DATA_DIR, SetupDefaults +from pystache import defaults +from pystache.loader import Loader + + +class LoaderTests(unittest.TestCase, AssertStringMixin, SetupDefaults): + + def setUp(self): + self.setup_defaults() + + def tearDown(self): + self.teardown_defaults() + + def test_init__extension(self): + loader = Loader(extension='foo') + self.assertEqual(loader.extension, 'foo') + + def test_init__extension__default(self): + # Test the default value. + loader = Loader() + self.assertEqual(loader.extension, 'mustache') + + def test_init__file_encoding(self): + loader = Loader(file_encoding='bar') + self.assertEqual(loader.file_encoding, 'bar') + + def test_init__file_encoding__default(self): + file_encoding = defaults.FILE_ENCODING + try: + defaults.FILE_ENCODING = 'foo' + loader = Loader() + self.assertEqual(loader.file_encoding, 'foo') + finally: + defaults.FILE_ENCODING = file_encoding + + def test_init__to_unicode(self): + to_unicode = lambda x: x + loader = Loader(to_unicode=to_unicode) + self.assertEqual(loader.to_unicode, to_unicode) + + def test_init__to_unicode__default(self): + loader = Loader() + self.assertRaises(TypeError, loader.to_unicode, u"abc") + + decode_errors = defaults.DECODE_ERRORS + string_encoding = defaults.STRING_ENCODING + + nonascii = u'abcdé'.encode('utf-8') + + loader = Loader() + self.assertRaises(UnicodeDecodeError, loader.to_unicode, nonascii) + + defaults.DECODE_ERRORS = 'ignore' + loader = Loader() + self.assertString(loader.to_unicode(nonascii), u'abcd') + + defaults.STRING_ENCODING = 'utf-8' + loader = Loader() + self.assertString(loader.to_unicode(nonascii), u'abcdé') + + + def _get_path(self, filename): + return os.path.join(DATA_DIR, filename) + + def test_unicode__basic__input_str(self): + """ + Test unicode(): default arguments with str input. + + """ + loader = Loader() + actual = loader.unicode("foo") + + self.assertString(actual, u"foo") + + def test_unicode__basic__input_unicode(self): + """ + Test unicode(): default arguments with unicode input. + + """ + loader = Loader() + actual = loader.unicode(u"foo") + + self.assertString(actual, u"foo") + + def test_unicode__basic__input_unicode_subclass(self): + """ + Test unicode(): default arguments with unicode-subclass input. + + """ + class UnicodeSubclass(unicode): + pass + + s = UnicodeSubclass(u"foo") + + loader = Loader() + actual = loader.unicode(s) + + self.assertString(actual, u"foo") + + def test_unicode__to_unicode__attribute(self): + """ + Test unicode(): encoding attribute. + + """ + loader = Loader() + + non_ascii = u'abcdé'.encode('utf-8') + self.assertRaises(UnicodeDecodeError, loader.unicode, non_ascii) + + def to_unicode(s, encoding=None): + if encoding is None: + encoding = 'utf-8' + return unicode(s, encoding) + + loader.to_unicode = to_unicode + self.assertString(loader.unicode(non_ascii), u"abcdé") + + def test_unicode__encoding_argument(self): + """ + Test unicode(): encoding argument. + + """ + loader = Loader() + + non_ascii = u'abcdé'.encode('utf-8') + + self.assertRaises(UnicodeDecodeError, loader.unicode, non_ascii) + + actual = loader.unicode(non_ascii, encoding='utf-8') + self.assertString(actual, u'abcdé') + + # TODO: check the read() unit tests. + def test_read(self): + """ + Test read(). + + """ + loader = Loader() + path = self._get_path('ascii.mustache') + actual = loader.read(path) + self.assertString(actual, u'ascii: abc') + + def test_read__file_encoding__attribute(self): + """ + Test read(): file_encoding attribute respected. + + """ + loader = Loader() + path = self._get_path('non_ascii.mustache') + + self.assertRaises(UnicodeDecodeError, loader.read, path) + + loader.file_encoding = 'utf-8' + actual = loader.read(path) + self.assertString(actual, u'non-ascii: é') + + def test_read__encoding__argument(self): + """ + Test read(): encoding argument respected. + + """ + loader = Loader() + path = self._get_path('non_ascii.mustache') + + self.assertRaises(UnicodeDecodeError, loader.read, path) + + actual = loader.read(path, encoding='utf-8') + self.assertString(actual, u'non-ascii: é') + + def test_loader__to_unicode__attribute(self): + """ + Test read(): to_unicode attribute respected. + + """ + loader = Loader() + path = self._get_path('non_ascii.mustache') + + self.assertRaises(UnicodeDecodeError, loader.read, path) + + #loader.decode_errors = 'ignore' + #actual = loader.read(path) + #self.assertString(actual, u'non-ascii: ') + diff --git a/pystache/tests/test_locator.py b/pystache/tests/test_locator.py new file mode 100644 index 0000000..3a8b229 --- /dev/null +++ b/pystache/tests/test_locator.py @@ -0,0 +1,157 @@ +# encoding: utf-8 + +""" +Unit tests for locator.py. + +""" + +from datetime import datetime +import os +import sys +import unittest + +# TODO: remove this alias. +from pystache.loader import Loader as Reader +from pystache.locator import Locator + +from pystache.tests.common import DATA_DIR, EXAMPLES_DIR +from pystache.tests.data.views import SayHello + + +class LocatorTests(unittest.TestCase): + + def _locator(self): + return Locator(search_dirs=DATA_DIR) + + def test_init__extension(self): + # Test the default value. + locator = Locator() + self.assertEqual(locator.template_extension, 'mustache') + + locator = Locator(extension='txt') + self.assertEqual(locator.template_extension, 'txt') + + locator = Locator(extension=False) + self.assertTrue(locator.template_extension is False) + + def _assert_paths(self, actual, expected): + """ + Assert that two paths are the same. + + """ + self.assertEqual(actual, expected) + + def test_get_object_directory(self): + locator = Locator() + + obj = SayHello() + actual = locator.get_object_directory(obj) + + self._assert_paths(actual, DATA_DIR) + + def test_get_object_directory__not_hasattr_module(self): + locator = Locator() + + obj = datetime(2000, 1, 1) + self.assertFalse(hasattr(obj, '__module__')) + self.assertEqual(locator.get_object_directory(obj), None) + + self.assertFalse(hasattr(None, '__module__')) + self.assertEqual(locator.get_object_directory(None), None) + + def test_make_file_name(self): + locator = Locator() + + locator.template_extension = 'bar' + self.assertEqual(locator.make_file_name('foo'), 'foo.bar') + + locator.template_extension = False + self.assertEqual(locator.make_file_name('foo'), 'foo') + + locator.template_extension = '' + self.assertEqual(locator.make_file_name('foo'), 'foo.') + + def test_make_file_name__template_extension_argument(self): + locator = Locator() + + self.assertEqual(locator.make_file_name('foo', template_extension='bar'), 'foo.bar') + + def test_find_name(self): + locator = Locator() + path = locator.find_name(search_dirs=[EXAMPLES_DIR], template_name='simple') + + self.assertEqual(os.path.basename(path), 'simple.mustache') + + def test_find_name__using_list_of_paths(self): + locator = Locator() + path = locator.find_name(search_dirs=[EXAMPLES_DIR, 'doesnt_exist'], template_name='simple') + + self.assertTrue(path) + + def test_find_name__precedence(self): + """ + Test the order in which find_name() searches directories. + + """ + locator = Locator() + + dir1 = DATA_DIR + dir2 = os.path.join(DATA_DIR, 'locator') + + self.assertTrue(locator.find_name(search_dirs=[dir1], template_name='duplicate')) + self.assertTrue(locator.find_name(search_dirs=[dir2], template_name='duplicate')) + + path = locator.find_name(search_dirs=[dir2, dir1], template_name='duplicate') + dirpath = os.path.dirname(path) + dirname = os.path.split(dirpath)[-1] + + self.assertEqual(dirname, 'locator') + + def test_find_name__non_existent_template_fails(self): + locator = Locator() + + self.assertRaises(IOError, locator.find_name, search_dirs=[], template_name='doesnt_exist') + + def test_find_object(self): + locator = Locator() + + obj = SayHello() + + actual = locator.find_object(search_dirs=[], obj=obj, file_name='sample_view.mustache') + expected = os.path.join(DATA_DIR, 'sample_view.mustache') + + self._assert_paths(actual, expected) + + def test_find_object__none_file_name(self): + locator = Locator() + + obj = SayHello() + + actual = locator.find_object(search_dirs=[], obj=obj) + expected = os.path.join(DATA_DIR, 'say_hello.mustache') + + self.assertEqual(actual, expected) + + def test_find_object__none_object_directory(self): + locator = Locator() + + obj = None + self.assertEqual(None, locator.get_object_directory(obj)) + + actual = locator.find_object(search_dirs=[DATA_DIR], obj=obj, file_name='say_hello.mustache') + expected = os.path.join(DATA_DIR, 'say_hello.mustache') + + self.assertEqual(actual, expected) + + def test_make_template_name(self): + """ + Test make_template_name(). + + """ + locator = Locator() + + class FooBar(object): + pass + foo = FooBar() + + self.assertEqual(locator.make_template_name(foo), 'foo_bar') 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_pystache.py b/pystache/tests/test_pystache.py new file mode 100644 index 0000000..5447f8d --- /dev/null +++ b/pystache/tests/test_pystache.py @@ -0,0 +1,127 @@ +# encoding: utf-8 + +import unittest + +import pystache +from pystache import defaults +from pystache import renderer +from pystache.tests.common import html_escape + + +class PystacheTests(unittest.TestCase): + + + def setUp(self): + self.original_escape = defaults.TAG_ESCAPE + defaults.TAG_ESCAPE = html_escape + + def tearDown(self): + defaults.TAG_ESCAPE = self.original_escape + + def _assert_rendered(self, expected, template, context): + actual = pystache.render(template, context) + self.assertEqual(actual, expected) + + def test_basic(self): + ret = pystache.render("Hi {{thing}}!", { 'thing': 'world' }) + self.assertEqual(ret, "Hi world!") + + def test_kwargs(self): + ret = pystache.render("Hi {{thing}}!", thing='world') + self.assertEqual(ret, "Hi world!") + + def test_less_basic(self): + template = "It's a nice day for {{beverage}}, right {{person}}?" + context = { 'beverage': 'soda', 'person': 'Bob' } + self._assert_rendered("It's a nice day for soda, right Bob?", template, context) + + def test_even_less_basic(self): + template = "I think {{name}} wants a {{thing}}, right {{name}}?" + context = { 'name': 'Jon', 'thing': 'racecar' } + self._assert_rendered("I think Jon wants a racecar, right Jon?", template, context) + + def test_ignores_misses(self): + template = "I think {{name}} wants a {{thing}}, right {{name}}?" + context = { 'name': 'Jon' } + self._assert_rendered("I think Jon wants a , right Jon?", template, context) + + def test_render_zero(self): + template = 'My value is {{value}}.' + context = { 'value': 0 } + self._assert_rendered('My value is 0.', template, context) + + def test_comments(self): + template = "What {{! the }} what?" + actual = pystache.render(template) + self.assertEqual("What what?", actual) + + def test_false_sections_are_hidden(self): + template = "Ready {{#set}}set {{/set}}go!" + context = { 'set': False } + self._assert_rendered("Ready go!", template, context) + + def test_true_sections_are_shown(self): + template = "Ready {{#set}}set{{/set}} go!" + context = { 'set': True } + self._assert_rendered("Ready set go!", template, context) + + non_strings_expected = """(123 & ['something'])(chris & 0.9)""" + + def test_non_strings(self): + template = "{{#stats}}({{key}} & {{value}}){{/stats}}" + stats = [] + stats.append({'key': 123, 'value': ['something']}) + stats.append({'key': u"chris", 'value': 0.900}) + context = { 'stats': stats } + self._assert_rendered(self.non_strings_expected, template, context) + + def test_unicode(self): + template = 'Name: {{name}}; Age: {{age}}' + context = {'name': u'Henri Poincaré', 'age': 156 } + self._assert_rendered(u'Name: Henri Poincaré; Age: 156', template, context) + + def test_sections(self): + template = """<ul>{{#users}}<li>{{name}}</li>{{/users}}</ul>""" + + context = { 'users': [ {'name': 'Chris'}, {'name': 'Tom'}, {'name': 'PJ'} ] } + expected = """<ul><li>Chris</li><li>Tom</li><li>PJ</li></ul>""" + self._assert_rendered(expected, template, context) + + def test_implicit_iterator(self): + template = """<ul>{{#users}}<li>{{.}}</li>{{/users}}</ul>""" + context = { 'users': [ 'Chris', 'Tom','PJ' ] } + expected = """<ul><li>Chris</li><li>Tom</li><li>PJ</li></ul>""" + self._assert_rendered(expected, template, context) + + # The spec says that sections should not alter surrounding whitespace. + def test_surrounding_whitepace_not_altered(self): + template = "first{{#spacing}} second {{/spacing}}third" + context = {"spacing": True} + self._assert_rendered("first second third", template, context) + + def test__section__non_false_value(self): + """ + Test when a section value is a (non-list) "non-false value". + + From mustache(5): + + When the value [of a section key] is non-false but not a list, it + will be used as the context for a single rendering of the block. + + """ + template = """{{#person}}Hi {{name}}{{/person}}""" + context = {"person": {"name": "Jon"}} + self._assert_rendered("Hi Jon", template, context) + + def test_later_list_section_with_escapable_character(self): + """ + This is a simple test case intended to cover issue #53. + + The test case failed with markupsafe enabled, as follows: + + AssertionError: Markup(u'foo <') != 'foo <' + + """ + template = """{{#s1}}foo{{/s1}} {{#s2}}<{{/s2}}""" + context = {'s1': True, 's2': [True]} + self._assert_rendered("foo <", template, context) diff --git a/pystache/tests/test_renderengine.py b/pystache/tests/test_renderengine.py new file mode 100644 index 0000000..2fcb95b --- /dev/null +++ b/pystache/tests/test_renderengine.py @@ -0,0 +1,610 @@ +# coding: utf-8 + +""" +Unit tests of renderengine.py. + +""" + +import unittest + +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, Attachable + + +def mock_literal(s): + """ + For use as the literal keyword argument to the RenderEngine constructor. + + Arguments: + + s: a byte string or unicode string. + + """ + if isinstance(s, unicode): + # Strip off unicode super classes, if present. + u = unicode(s) + else: + u = unicode(s, encoding='ascii') + + # We apply upper() to make sure we are actually using our custom + # function in the tests + return u.upper() + + + +class RenderEngineTestCase(unittest.TestCase): + + """Test the RenderEngine class.""" + + def test_init(self): + """ + Test that __init__() stores all of the arguments correctly. + + """ + # In real-life, these arguments would be functions + engine = RenderEngine(load_partial="foo", literal="literal", escape="escape") + + self.assertEqual(engine.escape, "escape") + self.assertEqual(engine.literal, "literal") + self.assertEqual(engine.load_partial, "foo") + + +class RenderTests(unittest.TestCase, AssertStringMixin): + + """ + Tests RenderEngine.render(). + + Explicit spec-test-like tests best go in this class since the + RenderEngine class contains all parsing logic. This way, the unit tests + will be more focused and fail "closer to the code". + + """ + + def _engine(self): + """ + Create and return a default RenderEngine for testing. + + """ + escape = defaults.TAG_ESCAPE + engine = RenderEngine(literal=unicode, escape=escape, load_partial=None) + return engine + + def _assert_render(self, expected, template, *context, **kwargs): + """ + Test rendering the given template using the given context. + + """ + partials = kwargs.get('partials') + engine = kwargs.get('engine', self._engine()) + + if partials is not None: + engine.load_partial = lambda key: unicode(partials[key]) + + context = ContextStack(*context) + + actual = engine.render(template, context) + + self.assertString(actual=actual, expected=expected) + + def test_render(self): + self._assert_render(u'Hi Mom', 'Hi {{person}}', {'person': 'Mom'}) + + def test__load_partial(self): + """ + Test that render() uses the load_template attribute. + + """ + engine = self._engine() + partials = {'partial': u"{{person}}"} + engine.load_partial = lambda key: partials[key] + + self._assert_render(u'Hi Mom', 'Hi {{>partial}}', {'person': 'Mom'}, engine=engine) + + def test__literal(self): + """ + Test that render() uses the literal attribute. + + """ + engine = self._engine() + engine.literal = lambda s: s.upper() + + self._assert_render(u'BAR', '{{{foo}}}', {'foo': 'bar'}, engine=engine) + + def test_literal__sigil(self): + template = "<h1>{{& thing}}</h1>" + context = {'thing': 'Bear > Giraffe'} + + expected = u"<h1>Bear > Giraffe</h1>" + + self._assert_render(expected, template, context) + + def test__escape(self): + """ + Test that render() uses the escape attribute. + + """ + engine = self._engine() + engine.escape = lambda s: "**" + s + + self._assert_render(u'**bar', '{{foo}}', {'foo': 'bar'}, engine=engine) + + def test__escape_does_not_call_literal(self): + """ + Test that render() does not call literal before or after calling escape. + + """ + engine = self._engine() + engine.literal = lambda s: s.upper() # a test version + engine.escape = lambda s: "**" + s + + template = 'literal: {{{foo}}} escaped: {{foo}}' + context = {'foo': 'bar'} + + self._assert_render(u'literal: BAR escaped: **bar', template, context, engine=engine) + + def test__escape_preserves_unicode_subclasses(self): + """ + Test that render() preserves unicode subclasses when passing to escape. + + This is useful, for example, if one wants to respect whether a + variable value is markupsafe.Markup when escaping. + + """ + class MyUnicode(unicode): + pass + + def escape(s): + if type(s) is MyUnicode: + return "**" + s + else: + return s + "**" + + engine = self._engine() + engine.escape = escape + + template = '{{foo1}} {{foo2}}' + context = {'foo1': MyUnicode('bar'), 'foo2': 'bar'} + + self._assert_render(u'**bar bar**', template, context, engine=engine) + + def test__non_basestring__literal_and_escaped(self): + """ + Test a context value that is not a basestring instance. + + """ + engine = self._engine() + engine.escape = mock_literal + engine.literal = mock_literal + + self.assertRaises(TypeError, engine.literal, 100) + + template = '{{text}} {{int}} {{{int}}}' + context = {'int': 100, 'text': 'foo'} + + self._assert_render(u'FOO 100 100', template, context, engine=engine) + + def test_tag__output_not_interpolated(self): + """ + Context values should not be treated as templates (issue #44). + + """ + template = '{{template}}: {{planet}}' + context = {'template': '{{planet}}', 'planet': 'Earth'} + self._assert_render(u'{{planet}}: Earth', template, context) + + def test_tag__output_not_interpolated__section(self): + """ + Context values should not be treated as templates (issue #44). + + """ + template = '{{test}}' + 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, + # for example by calling a method on a built-in type instance when it + # has a method whose name matches the current key. + # + # Each test case puts an instance of a built-in type on top of the + # context stack before interpolating a tag whose key matches an + # attribute (method or property) of the instance. + # + + def _assert_builtin_attr(self, item, attr_name, expected_attr): + self.assertTrue(hasattr(item, attr_name)) + actual = getattr(item, attr_name) + if callable(actual): + actual = actual() + self.assertEqual(actual, expected_attr) + + def _assert_builtin_type(self, item, attr_name, expected_attr, expected_template): + self._assert_builtin_attr(item, attr_name, expected_attr) + + template = '{{#section}}{{%s}}{{/section}}' % attr_name + context = {'section': item, attr_name: expected_template} + self._assert_render(expected_template, template, context) + + def test_interpolation__built_in_type__string(self): + """ + Check tag interpolation with a built-in type: string. + + """ + self._assert_builtin_type('abc', 'upper', 'ABC', u'xyz') + + def test_interpolation__built_in_type__integer(self): + """ + Check tag interpolation with a built-in type: integer. + + """ + # Since public attributes weren't added to integers until Python 2.6 + # (for example the "real" attribute of the numeric type hierarchy)-- + # + # http://docs.python.org/library/numbers.html + # + # we need to resort to built-in attributes (double-underscored) on + # the integer type. + self._assert_builtin_type(15, '__neg__', -15, u'999') + + def test_interpolation__built_in_type__list(self): + """ + Check tag interpolation with a built-in type: list. + + """ + item = [[1, 2, 3]] + attr_name = 'pop' + # Make a copy to prevent changes to item[0]. + self._assert_builtin_attr(list(item[0]), attr_name, 3) + + template = '{{#section}}{{%s}}{{/section}}' % attr_name + context = {'section': item, attr_name: 7} + self._assert_render(u'7', template, context) + + def test_implicit_iterator__literal(self): + """ + Test an implicit iterator in a literal tag. + + """ + template = """{{#test}}{{{.}}}{{/test}}""" + context = {'test': ['<', '>']} + + self._assert_render(u'<>', template, context) + + def test_implicit_iterator__escaped(self): + """ + Test an implicit iterator in a normal tag. + + """ + template = """{{#test}}{{.}}{{/test}}""" + context = {'test': ['<', '>']} + + self._assert_render(u'<>', template, context) + + def test_literal__in_section(self): + """ + Check that literals work in sections. + + """ + template = '{{#test}}1 {{{less_than}}} 2{{/test}}' + context = {'test': {'less_than': '<'}} + + self._assert_render(u'1 < 2', template, context) + + def test_literal__in_partial(self): + """ + Check that literals work in partials. + + """ + template = '{{>partial}}' + partials = {'partial': '1 {{{less_than}}} 2'} + context = {'less_than': '<'} + + self._assert_render(u'1 < 2', template, context, partials=partials) + + def test_partial(self): + partials = {'partial': "{{person}}"} + self._assert_render(u'Hi Mom', 'Hi {{>partial}}', {'person': 'Mom'}, partials=partials) + + def test_partial__context_values(self): + """ + Test that escape and literal work on context values in partials. + + """ + engine = self._engine() + + template = '{{>partial}}' + partials = {'partial': 'unescaped: {{{foo}}} escaped: {{foo}}'} + context = {'foo': '<'} + + self._assert_render(u'unescaped: < escaped: <', template, context, engine=engine, partials=partials) + + ## Test cases related specifically to sections. + + def test_section__end_tag_with_no_start_tag(self): + """ + Check what happens if there is an end tag with no start tag. + + """ + template = '{{/section}}' + try: + self._assert_render(None, template) + except ParsingError, err: + self.assertEqual(str(err), "Section end tag mismatch: section != None") + + def test_section__end_tag_mismatch(self): + """ + Check what happens if the end tag doesn't match. + + """ + template = '{{#section_start}}{{/section_end}}' + try: + self._assert_render(None, template) + except ParsingError, err: + self.assertEqual(str(err), "Section end tag mismatch: section_end != section_start") + + def test_section__context_values(self): + """ + Test that escape and literal work on context values in sections. + + """ + engine = self._engine() + + template = '{{#test}}unescaped: {{{foo}}} escaped: {{foo}}{{/test}}' + context = {'test': {'foo': '<'}} + + self._assert_render(u'unescaped: < escaped: <', template, context, engine=engine) + + def test_section__context_precedence(self): + """ + Check that items higher in the context stack take precedence. + + """ + template = '{{entree}} : {{#vegetarian}}{{entree}}{{/vegetarian}}' + context = {'entree': 'chicken', 'vegetarian': {'entree': 'beans and rice'}} + self._assert_render(u'chicken : beans and rice', template, context) + + def test_section__list_referencing_outer_context(self): + """ + Check that list items can access the parent context. + + For sections whose value is a list, check that items in the list + have access to the values inherited from the parent context + when rendering. + + """ + context = { + "greeting": "Hi", + "list": [{"name": "Al"}, {"name": "Bob"}], + } + + template = "{{#list}}{{greeting}} {{name}}, {{/list}}" + + self._assert_render(u"Hi Al, Hi Bob, ", template, context) + + def test_section__output_not_interpolated(self): + """ + Check that rendered section output is not interpolated. + + """ + template = '{{#section}}{{template}}{{/section}}: {{planet}}' + context = {'section': True, 'template': '{{planet}}', 'planet': 'Earth'} + self._assert_render(u'{{planet}}: Earth', template, context) + + # TODO: have this test case added to the spec. + def test_section__string_values_not_lists(self): + """ + Check that string section values are not interpreted as lists. + + """ + template = '{{#section}}foo{{/section}}' + context = {'section': '123'} + # If strings were interpreted as lists, this would give "foofoofoo". + self._assert_render(u'foo', template, context) + + def test_section__nested_truthy(self): + """ + Check that "nested truthy" sections get rendered. + + Test case for issue #24: https://github.com/defunkt/pystache/issues/24 + + This test is copied from the spec. We explicitly include it to + prevent regressions for those who don't pull down the spec tests. + + """ + template = '| A {{#bool}}B {{#bool}}C{{/bool}} D{{/bool}} E |' + context = {'bool': True} + self._assert_render(u'| A B C D E |', template, context) + + def test_section__nested_with_same_keys(self): + """ + Check a doubly-nested section with the same context key. + + Test case for issue #36: https://github.com/defunkt/pystache/issues/36 + + """ + # Start with an easier, working case. + template = '{{#x}}{{#z}}{{y}}{{/z}}{{/x}}' + context = {'x': {'z': {'y': 1}}} + self._assert_render(u'1', template, context) + + template = '{{#x}}{{#x}}{{y}}{{/x}}{{/x}}' + context = {'x': {'x': {'y': 1}}} + self._assert_render(u'1', template, context) + + def test_section__lambda(self): + template = '{{#test}}Mom{{/test}}' + context = {'test': (lambda text: 'Hi %s' % text)} + self._assert_render(u'Hi Mom', template, context) + + def test_section__iterable(self): + """ + Check that objects supporting iteration (aside from dicts) behave like lists. + + """ + template = '{{#iterable}}{{.}}{{/iterable}}' + + context = {'iterable': (i for i in range(3))} # type 'generator' + self._assert_render(u'012', template, context) + + context = {'iterable': xrange(4)} # type 'xrange' + self._assert_render(u'0123', template, context) + + d = {'foo': 0, 'bar': 0} + # We don't know what order of keys we'll be given, but from the + # Python documentation: + # "If items(), keys(), values(), iteritems(), iterkeys(), and + # itervalues() are called with no intervening modifications to + # the dictionary, the lists will directly correspond." + expected = u''.join(d.keys()) + context = {'iterable': d.iterkeys()} # type 'dictionary-keyiterator' + self._assert_render(expected, template, context) + + def test_section__lambda__tag_in_output(self): + """ + Check that callable output is treated as a template string (issue #46). + + The spec says-- + + 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. + + """ + template = '{{#test}}Hi {{person}}{{/test}}' + context = {'person': 'Mom', 'test': (lambda text: text + " :)")} + self._assert_render(u'Hi Mom :)', 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. + + """ + self._assert_render(u'foobar', 'foo{{! baz }}bar') + self._assert_render(u'foobar', 'foo{{! \nbaz }}bar') + + def test_custom_delimiters__sections(self): + """ + Check that custom delimiters can be used to start a section. + + Test case for issue #20: https://github.com/defunkt/pystache/issues/20 + + """ + template = '{{=[[ ]]=}}[[#foo]]bar[[/foo]]' + context = {'foo': True} + self._assert_render(u'bar', template, context) + + def test_custom_delimiters__not_retroactive(self): + """ + Check that changing custom delimiters back is not "retroactive." + + Test case for issue #35: https://github.com/defunkt/pystache/issues/35 + + """ + expected = u' {{foo}} ' + self._assert_render(expected, '{{=$ $=}} {{foo}} ') + self._assert_render(expected, '{{=$ $=}} {{foo}} $={{ }}=$') # was yielding u' '. + + def test_dot_notation(self): + """ + Check that we can use dot notation when the variable is a dict, + a used-defined object, or a 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): + """ + Check that, when using dot notation, if the key or attribute does not + exist then its value is rendered as empty + """ + 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): + """ + Check that using multiple levels of dot attributes works as expected + """ + 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) diff --git a/pystache/tests/test_renderer.py b/pystache/tests/test_renderer.py new file mode 100644 index 0000000..64a4325 --- /dev/null +++ b/pystache/tests/test_renderer.py @@ -0,0 +1,584 @@ +# coding: utf-8 + +""" +Unit tests of template.py. + +""" + +import codecs +import os +import sys +import unittest + +from examples.simple import Simple +from pystache import Renderer +from pystache import TemplateSpec +from pystache.loader import Loader + +from pystache.tests.common import get_data_path, AssertStringMixin +from pystache.tests.data.views import SayHello + + +def _make_renderer(): + """ + Return a default Renderer instance for testing purposes. + + """ + renderer = Renderer(string_encoding='ascii', file_encoding='ascii') + return renderer + + +def mock_unicode(b, encoding=None): + if encoding is None: + encoding = 'ascii' + u = unicode(b, encoding=encoding) + return u.upper() + + +class RendererInitTestCase(unittest.TestCase): + + """ + Tests the Renderer.__init__() method. + + """ + + def test_partials__default(self): + """ + Test the default value. + + """ + renderer = Renderer() + self.assertTrue(renderer.partials is None) + + def test_partials(self): + """ + Test that the attribute is set correctly. + + """ + renderer = Renderer(partials={'foo': 'bar'}) + self.assertEqual(renderer.partials, {'foo': 'bar'}) + + def test_escape__default(self): + escape = Renderer().escape + + self.assertEqual(escape(">"), ">") + self.assertEqual(escape('"'), """) + # Single quotes are escaped only in Python 3.2 and later. + if sys.version_info < (3, 2): + expected = "'" + else: + expected = ''' + self.assertEqual(escape("'"), expected) + + def test_escape(self): + escape = lambda s: "**" + s + renderer = Renderer(escape=escape) + self.assertEqual(renderer.escape("bar"), "**bar") + + def test_decode_errors__default(self): + """ + Check the default value. + + """ + renderer = Renderer() + self.assertEqual(renderer.decode_errors, 'strict') + + def test_decode_errors(self): + """ + Check that the constructor sets the attribute correctly. + + """ + renderer = Renderer(decode_errors="foo") + self.assertEqual(renderer.decode_errors, "foo") + + def test_file_encoding__default(self): + """ + Check the file_encoding default. + + """ + renderer = Renderer() + self.assertEqual(renderer.file_encoding, renderer.string_encoding) + + def test_file_encoding(self): + """ + Check that the file_encoding attribute is set correctly. + + """ + renderer = Renderer(file_encoding='foo') + self.assertEqual(renderer.file_encoding, 'foo') + + def test_file_extension__default(self): + """ + Check the file_extension default. + + """ + renderer = Renderer() + self.assertEqual(renderer.file_extension, 'mustache') + + def test_file_extension(self): + """ + Check that the file_encoding attribute is set correctly. + + """ + renderer = Renderer(file_extension='foo') + self.assertEqual(renderer.file_extension, 'foo') + + def test_search_dirs__default(self): + """ + Check the search_dirs default. + + """ + renderer = Renderer() + self.assertEqual(renderer.search_dirs, [os.curdir]) + + def test_search_dirs__string(self): + """ + Check that the search_dirs attribute is set correctly when a string. + + """ + renderer = Renderer(search_dirs='foo') + self.assertEqual(renderer.search_dirs, ['foo']) + + def test_search_dirs__list(self): + """ + Check that the search_dirs attribute is set correctly when a list. + + """ + renderer = Renderer(search_dirs=['foo']) + self.assertEqual(renderer.search_dirs, ['foo']) + + def test_string_encoding__default(self): + """ + Check the default value. + + """ + renderer = Renderer() + self.assertEqual(renderer.string_encoding, sys.getdefaultencoding()) + + def test_string_encoding(self): + """ + Check that the constructor sets the attribute correctly. + + """ + renderer = Renderer(string_encoding="foo") + self.assertEqual(renderer.string_encoding, "foo") + + +class RendererTests(unittest.TestCase, AssertStringMixin): + + """Test the Renderer class.""" + + def _renderer(self): + return Renderer() + + ## Test Renderer.unicode(). + + def test_unicode__string_encoding(self): + """ + Test that the string_encoding attribute is respected. + + """ + renderer = self._renderer() + b = u"é".encode('utf-8') + + renderer.string_encoding = "ascii" + self.assertRaises(UnicodeDecodeError, renderer.unicode, b) + + renderer.string_encoding = "utf-8" + self.assertEqual(renderer.unicode(b), u"é") + + def test_unicode__decode_errors(self): + """ + Test that the decode_errors attribute is respected. + + """ + renderer = self._renderer() + renderer.string_encoding = "ascii" + b = u"déf".encode('utf-8') + + renderer.decode_errors = "ignore" + self.assertEqual(renderer.unicode(b), "df") + + renderer.decode_errors = "replace" + # U+FFFD is the official Unicode replacement character. + self.assertEqual(renderer.unicode(b), u'd\ufffd\ufffdf') + + ## Test the _make_loader() method. + + def test__make_loader__return_type(self): + """ + Test that _make_loader() returns a Loader. + + """ + renderer = self._renderer() + loader = renderer._make_loader() + + self.assertEqual(type(loader), Loader) + + def test__make_loader__attributes(self): + """ + Test that _make_loader() sets all attributes correctly.. + + """ + unicode_ = lambda x: x + + renderer = self._renderer() + renderer.file_encoding = 'enc' + renderer.file_extension = 'ext' + renderer.unicode = unicode_ + + loader = renderer._make_loader() + + self.assertEqual(loader.extension, 'ext') + self.assertEqual(loader.file_encoding, 'enc') + self.assertEqual(loader.to_unicode, unicode_) + + ## Test the render() method. + + def test_render__return_type(self): + """ + Check that render() returns a string of type unicode. + + """ + renderer = self._renderer() + rendered = renderer.render('foo') + self.assertEqual(type(rendered), unicode) + + def test_render__unicode(self): + renderer = self._renderer() + actual = renderer.render(u'foo') + self.assertEqual(actual, u'foo') + + def test_render__str(self): + renderer = self._renderer() + actual = renderer.render('foo') + self.assertEqual(actual, 'foo') + + def test_render__non_ascii_character(self): + renderer = self._renderer() + actual = renderer.render(u'Poincaré') + self.assertEqual(actual, u'Poincaré') + + def test_render__context(self): + """ + Test render(): passing a context. + + """ + renderer = self._renderer() + self.assertEqual(renderer.render('Hi {{person}}', {'person': 'Mom'}), 'Hi Mom') + + def test_render__context_and_kwargs(self): + """ + Test render(): passing a context and **kwargs. + + """ + renderer = self._renderer() + template = 'Hi {{person1}} and {{person2}}' + self.assertEqual(renderer.render(template, {'person1': 'Mom'}, person2='Dad'), 'Hi Mom and Dad') + + def test_render__kwargs_and_no_context(self): + """ + Test render(): passing **kwargs and no context. + + """ + renderer = self._renderer() + self.assertEqual(renderer.render('Hi {{person}}', person='Mom'), 'Hi Mom') + + def test_render__context_and_kwargs__precedence(self): + """ + Test render(): **kwargs takes precedence over context. + + """ + renderer = self._renderer() + self.assertEqual(renderer.render('Hi {{person}}', {'person': 'Mom'}, person='Dad'), 'Hi Dad') + + def test_render__kwargs_does_not_modify_context(self): + """ + Test render(): passing **kwargs does not modify the passed context. + + """ + context = {} + renderer = self._renderer() + renderer.render('Hi {{person}}', context=context, foo="bar") + self.assertEqual(context, {}) + + def test_render__nonascii_template(self): + """ + Test passing a non-unicode template with non-ascii characters. + + """ + renderer = _make_renderer() + template = u"déf".encode("utf-8") + + # Check that decode_errors and string_encoding are both respected. + renderer.decode_errors = 'ignore' + renderer.string_encoding = 'ascii' + self.assertEqual(renderer.render(template), "df") + + renderer.string_encoding = 'utf_8' + self.assertEqual(renderer.render(template), u"déf") + + def test_make_load_partial(self): + """ + Test the _make_load_partial() method. + + """ + renderer = Renderer() + renderer.partials = {'foo': 'bar'} + load_partial = renderer._make_load_partial() + + actual = load_partial('foo') + self.assertEqual(actual, 'bar') + self.assertEqual(type(actual), unicode, "RenderEngine requires that " + "load_partial return unicode strings.") + + def test_make_load_partial__unicode(self): + """ + Test _make_load_partial(): that load_partial doesn't "double-decode" Unicode. + + """ + renderer = Renderer() + + renderer.partials = {'partial': 'foo'} + load_partial = renderer._make_load_partial() + self.assertEqual(load_partial("partial"), "foo") + + # Now with a value that is already unicode. + renderer.partials = {'partial': u'foo'} + load_partial = renderer._make_load_partial() + # If the next line failed, we would get the following error: + # TypeError: decoding Unicode is not supported + self.assertEqual(load_partial("partial"), "foo") + + def test_render_path(self): + """ + Test the render_path() method. + + """ + renderer = Renderer() + path = get_data_path('say_hello.mustache') + actual = renderer.render_path(path, to='foo') + self.assertEqual(actual, "Hello, foo") + + def test_render__object(self): + """ + Test rendering an object instance. + + """ + renderer = Renderer() + + say_hello = SayHello() + actual = renderer.render(say_hello) + self.assertEqual('Hello, World', actual) + + actual = renderer.render(say_hello, to='Mars') + self.assertEqual('Hello, Mars', actual) + + def test_render__template_spec(self): + """ + Test rendering a TemplateSpec instance. + + """ + renderer = Renderer() + + class Spec(TemplateSpec): + template = "hello, {{to}}" + to = 'world' + + spec = Spec() + actual = renderer.render(spec) + self.assertString(actual, u'hello, world') + + def test_render__view(self): + """ + Test rendering a View instance. + + """ + renderer = Renderer() + + view = Simple() + actual = renderer.render(view) + self.assertEqual('Hi pizza!', actual) + + +# By testing that Renderer.render() constructs the right RenderEngine, +# 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): + + """ + Check the RenderEngine returned by Renderer._make_render_engine(). + + """ + + def _make_renderer(self): + """ + Return a default Renderer instance for testing purposes. + + """ + return _make_renderer() + + ## Test the engine's load_partial attribute. + + def test__load_partial__returns_unicode(self): + """ + Check that load_partial returns unicode (and not a subclass). + + """ + class MyUnicode(unicode): + pass + + renderer = Renderer() + renderer.string_encoding = 'ascii' + renderer.partials = {'str': 'foo', 'subclass': MyUnicode('abc')} + + engine = renderer._make_render_engine() + + actual = engine.load_partial('str') + self.assertEqual(actual, "foo") + self.assertEqual(type(actual), unicode) + + # Check that unicode subclasses are not preserved. + actual = engine.load_partial('subclass') + self.assertEqual(actual, "abc") + self.assertEqual(type(actual), unicode) + + def test__load_partial__not_found(self): + """ + Check that load_partial provides a nice message when a template is not found. + + """ + renderer = Renderer() + renderer.partials = {} + + 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'") + + ## Test the engine's literal attribute. + + def test__literal__uses_renderer_unicode(self): + """ + Test that literal uses the renderer's unicode function. + + """ + renderer = self._make_renderer() + renderer.unicode = mock_unicode + + engine = renderer._make_render_engine() + literal = engine.literal + + b = u"foo".encode("ascii") + self.assertEqual(literal(b), "FOO") + + def test__literal__handles_unicode(self): + """ + Test that literal doesn't try to "double decode" unicode. + + """ + renderer = Renderer() + renderer.string_encoding = 'ascii' + + engine = renderer._make_render_engine() + literal = engine.literal + + self.assertEqual(literal(u"foo"), "foo") + + def test__literal__returns_unicode(self): + """ + Test that literal returns unicode (and not a subclass). + + """ + renderer = Renderer() + renderer.string_encoding = 'ascii' + + engine = renderer._make_render_engine() + literal = engine.literal + + self.assertEqual(type(literal("foo")), unicode) + + class MyUnicode(unicode): + pass + + s = MyUnicode("abc") + + self.assertEqual(type(s), MyUnicode) + self.assertTrue(isinstance(s, unicode)) + self.assertEqual(type(literal(s)), unicode) + + ## Test the engine's escape attribute. + + def test__escape__uses_renderer_escape(self): + """ + Test that escape uses the renderer's escape function. + + """ + renderer = Renderer() + renderer.escape = lambda s: "**" + s + + engine = renderer._make_render_engine() + escape = engine.escape + + self.assertEqual(escape("foo"), "**foo") + + def test__escape__uses_renderer_unicode(self): + """ + Test that escape uses the renderer's unicode function. + + """ + renderer = Renderer() + renderer.unicode = mock_unicode + + engine = renderer._make_render_engine() + escape = engine.escape + + b = u"foo".encode('ascii') + self.assertEqual(escape(b), "FOO") + + def test__escape__has_access_to_original_unicode_subclass(self): + """ + Test that escape receives strings with the unicode subclass intact. + + """ + renderer = Renderer() + renderer.escape = lambda s: unicode(type(s).__name__) + + engine = renderer._make_render_engine() + escape = engine.escape + + class MyUnicode(unicode): + pass + + self.assertEqual(escape(u"foo".encode('ascii')), unicode.__name__) + self.assertEqual(escape(u"foo"), unicode.__name__) + self.assertEqual(escape(MyUnicode("foo")), MyUnicode.__name__) + + def test__escape__returns_unicode(self): + """ + Test that literal returns unicode (and not a subclass). + + """ + renderer = Renderer() + renderer.string_encoding = 'ascii' + + engine = renderer._make_render_engine() + escape = engine.escape + + self.assertEqual(type(escape("foo")), unicode) + + # Check that literal doesn't preserve unicode subclasses. + class MyUnicode(unicode): + pass + + s = MyUnicode("abc") + + self.assertEqual(type(s), MyUnicode) + self.assertTrue(isinstance(s, unicode)) + self.assertEqual(type(escape(s)), unicode) + diff --git a/pystache/tests/test_simple.py b/pystache/tests/test_simple.py new file mode 100644 index 0000000..d3ed0b6 --- /dev/null +++ b/pystache/tests/test_simple.py @@ -0,0 +1,82 @@ +import unittest + +import pystache +from pystache import Renderer +from examples.nested_context import NestedContext +from examples.complex import Complex +from examples.lambdas import Lambdas +from examples.template_partial import TemplatePartial +from examples.simple import Simple + +from pystache.tests.common import EXAMPLES_DIR +from pystache.tests.common import AssertStringMixin + + +class TestSimple(unittest.TestCase, AssertStringMixin): + + def test_nested_context(self): + renderer = Renderer() + view = NestedContext(renderer) + view.template = '{{#foo}}{{thing1}} and {{thing2}} and {{outer_thing}}{{/foo}}{{^foo}}Not foo!{{/foo}}' + + actual = renderer.render(view) + self.assertString(actual, u"one and foo and two") + + def test_looping_and_negation_context(self): + template = '{{#item}}{{header}}: {{name}} {{/item}}{{^item}} Shouldnt see me{{/item}}' + context = Complex() + + renderer = Renderer() + actual = renderer.render(template, context) + self.assertEqual(actual, "Colors: red Colors: green Colors: blue ") + + def test_empty_context(self): + template = '{{#empty_list}}Shouldnt see me {{/empty_list}}{{^empty_list}}Should see me{{/empty_list}}' + self.assertEqual(pystache.Renderer().render(template), "Should see me") + + def test_callables(self): + view = Lambdas() + view.template = '{{#replace_foo_with_bar}}foo != bar. oh, it does!{{/replace_foo_with_bar}}' + + renderer = Renderer() + actual = renderer.render(view) + self.assertString(actual, u'bar != bar. oh, it does!') + + def test_rendering_partial(self): + renderer = Renderer(search_dirs=EXAMPLES_DIR) + + view = TemplatePartial(renderer=renderer) + view.template = '{{>inner_partial}}' + + actual = renderer.render(view) + self.assertString(actual, u'Again, Welcome!') + + view.template = '{{#looping}}{{>inner_partial}} {{/looping}}' + actual = renderer.render(view) + self.assertString(actual, u"Again, Welcome! Again, Welcome! Again, Welcome! ") + + def test_non_existent_value_renders_blank(self): + view = Simple() + template = '{{not_set}} {{blank}}' + self.assertEqual(pystache.Renderer().render(template), ' ') + + def test_template_partial_extension(self): + """ + Side note: + + From the spec-- + + Partial tags SHOULD be treated as standalone when appropriate. + + In particular, this means that trailing newlines should be removed. + + """ + renderer = Renderer(search_dirs=EXAMPLES_DIR, file_extension='txt') + + view = TemplatePartial(renderer=renderer) + + actual = renderer.render(view) + self.assertString(actual, u"""Welcome +------- + +## Again, Welcome! ##""") diff --git a/pystache/tests/test_specloader.py b/pystache/tests/test_specloader.py new file mode 100644 index 0000000..8332b28 --- /dev/null +++ b/pystache/tests/test_specloader.py @@ -0,0 +1,423 @@ +# coding: utf-8 + +""" +Unit tests for template_spec.py. + +""" + +import os.path +import sys +import unittest + +import examples +from examples.simple import Simple +from examples.complex import Complex +from examples.lambdas import Lambdas +from examples.inverted import Inverted, InvertedLists +from pystache import Renderer +from pystache import TemplateSpec +from pystache.locator import Locator +from pystache.loader import Loader +from pystache.specloader import SpecLoader +from pystache.tests.common import DATA_DIR, EXAMPLES_DIR +from pystache.tests.common import AssertIsMixin, AssertStringMixin +from pystache.tests.data.views import SampleView +from pystache.tests.data.views import NonAscii + + +class Thing(object): + pass + + +class ViewTestCase(unittest.TestCase, AssertStringMixin): + + def test_template_rel_directory(self): + """ + Test that View.template_rel_directory is respected. + + """ + class Tagless(TemplateSpec): + pass + + view = Tagless() + renderer = Renderer() + + self.assertRaises(IOError, renderer.render, view) + + # TODO: change this test to remove the following brittle line. + view.template_rel_directory = "examples" + actual = renderer.render(view) + self.assertEqual(actual, "No tags...") + + def test_template_path_for_partials(self): + """ + Test that View.template_rel_path is respected for partials. + + """ + spec = TemplateSpec() + spec.template = "Partial: {{>tagless}}" + + renderer1 = Renderer() + renderer2 = Renderer(search_dirs=EXAMPLES_DIR) + + self.assertRaises(IOError, renderer1.render, spec) + + actual = renderer2.render(spec) + self.assertEqual(actual, "Partial: No tags...") + + def test_basic_method_calls(self): + renderer = Renderer() + actual = renderer.render(Simple()) + + self.assertString(actual, u"Hi pizza!") + + def test_non_callable_attributes(self): + view = Simple() + view.thing = 'Chris' + + renderer = Renderer() + actual = renderer.render(view) + self.assertEqual(actual, "Hi Chris!") + + def test_complex(self): + renderer = Renderer() + actual = renderer.render(Complex()) + self.assertString(actual, u"""\ +<h1>Colors</h1> +<ul> +<li><strong>red</strong></li> +<li><a href="#Green">green</a></li> +<li><a href="#Blue">blue</a></li> +</ul>""") + + def test_higher_order_replace(self): + renderer = Renderer() + actual = renderer.render(Lambdas()) + self.assertEqual(actual, 'bar != bar. oh, it does!') + + def test_higher_order_rot13(self): + view = Lambdas() + view.template = '{{#rot13}}abcdefghijklm{{/rot13}}' + + renderer = Renderer() + actual = renderer.render(view) + self.assertString(actual, u'nopqrstuvwxyz') + + def test_higher_order_lambda(self): + view = Lambdas() + view.template = '{{#sort}}zyxwvutsrqponmlkjihgfedcba{{/sort}}' + + renderer = Renderer() + actual = renderer.render(view) + self.assertString(actual, u'abcdefghijklmnopqrstuvwxyz') + + def test_partials_with_lambda(self): + view = Lambdas() + view.template = '{{>partial_with_lambda}}' + + renderer = Renderer(search_dirs=EXAMPLES_DIR) + actual = renderer.render(view) + self.assertEqual(actual, u'nopqrstuvwxyz') + + def test_hierarchical_partials_with_lambdas(self): + view = Lambdas() + view.template = '{{>partial_with_partial_and_lambda}}' + + renderer = Renderer(search_dirs=EXAMPLES_DIR) + actual = renderer.render(view) + self.assertString(actual, u'nopqrstuvwxyznopqrstuvwxyz') + + def test_inverted(self): + renderer = Renderer() + actual = renderer.render(Inverted()) + self.assertString(actual, u"""one, two, three, empty list""") + + def test_accessing_properties_on_parent_object_from_child_objects(self): + parent = Thing() + parent.this = 'derp' + parent.children = [Thing()] + view = Simple() + view.template = "{{#parent}}{{#children}}{{this}}{{/children}}{{/parent}}" + + renderer = Renderer() + actual = renderer.render(view, {'parent': parent}) + + self.assertString(actual, u'derp') + + def test_inverted_lists(self): + renderer = Renderer() + actual = renderer.render(InvertedLists()) + self.assertString(actual, u"""one, two, three, empty list""") + + +def _make_specloader(): + """ + Return a default SpecLoader instance for testing purposes. + + """ + # Python 2 and 3 have different default encodings. Thus, to have + # consistent test results across both versions, we need to specify + # the string and file encodings explicitly rather than relying on + # the defaults. + def to_unicode(s, encoding=None): + """ + Raises a TypeError exception if the given string is already unicode. + + """ + if encoding is None: + encoding = 'ascii' + return unicode(s, encoding, 'strict') + + loader = Loader(file_encoding='ascii', to_unicode=to_unicode) + return SpecLoader(loader=loader) + + +class SpecLoaderTests(unittest.TestCase, AssertIsMixin, AssertStringMixin): + + """ + Tests template_spec.SpecLoader. + + """ + + def _make_specloader(self): + return _make_specloader() + + def test_init__defaults(self): + spec_loader = SpecLoader() + + # Check the loader attribute. + loader = spec_loader.loader + self.assertEqual(loader.extension, 'mustache') + self.assertEqual(loader.file_encoding, sys.getdefaultencoding()) + # TODO: finish testing the other Loader attributes. + to_unicode = loader.to_unicode + + def test_init__loader(self): + loader = Loader() + custom = SpecLoader(loader=loader) + + self.assertIs(custom.loader, loader) + + # TODO: rename to something like _assert_load(). + def _assert_template(self, loader, custom, expected): + self.assertString(loader.load(custom), expected) + + def test_load__template__type_str(self): + """ + Test the template attribute: str string. + + """ + custom = TemplateSpec() + custom.template = "abc" + + spec_loader = self._make_specloader() + self._assert_template(spec_loader, custom, u"abc") + + def test_load__template__type_unicode(self): + """ + Test the template attribute: unicode string. + + """ + custom = TemplateSpec() + custom.template = u"abc" + + spec_loader = self._make_specloader() + self._assert_template(spec_loader, custom, u"abc") + + def test_load__template__unicode_non_ascii(self): + """ + Test the template attribute: non-ascii unicode string. + + """ + custom = TemplateSpec() + custom.template = u"é" + + spec_loader = self._make_specloader() + self._assert_template(spec_loader, custom, u"é") + + def test_load__template__with_template_encoding(self): + """ + Test the template attribute: with template encoding attribute. + + """ + custom = TemplateSpec() + custom.template = u'é'.encode('utf-8') + + spec_loader = self._make_specloader() + + self.assertRaises(UnicodeDecodeError, self._assert_template, spec_loader, custom, u'é') + + custom.template_encoding = 'utf-8' + self._assert_template(spec_loader, custom, u'é') + + # TODO: make this test complete. + def test_load__template__correct_loader(self): + """ + Test that reader.unicode() is called correctly. + + This test tests that the correct reader is called with the correct + arguments. This is a catch-all test to supplement the other + test cases. It tests SpecLoader.load() independent of reader.unicode() + being implemented correctly (and tested). + + """ + class MockLoader(Loader): + + def __init__(self): + self.s = None + self.encoding = None + + # Overrides the existing method. + def unicode(self, s, encoding=None): + self.s = s + self.encoding = encoding + return u"foo" + + loader = MockLoader() + custom_loader = SpecLoader() + custom_loader.loader = loader + + view = TemplateSpec() + view.template = "template-foo" + view.template_encoding = "encoding-foo" + + # Check that our unicode() above was called. + self._assert_template(custom_loader, view, u'foo') + self.assertEqual(loader.s, "template-foo") + self.assertEqual(loader.encoding, "encoding-foo") + + +# TODO: migrate these tests into the SpecLoaderTests class. +# TODO: rename the get_template() tests to test load(). +# TODO: condense, reorganize, and rename the tests so that it is +# clear whether we have full test coverage (e.g. organized by +# TemplateSpec attributes or something). +class TemplateSpecTests(unittest.TestCase): + + def _make_loader(self): + return _make_specloader() + + def _assert_template_location(self, view, expected): + loader = self._make_loader() + actual = loader._find_relative(view) + self.assertEqual(actual, expected) + + def test_find_relative(self): + """ + Test _find_relative(): default behavior (no attributes set). + + """ + view = SampleView() + self._assert_template_location(view, (None, 'sample_view.mustache')) + + def test_find_relative__template_rel_path__file_name_only(self): + """ + Test _find_relative(): template_rel_path attribute. + + """ + view = SampleView() + view.template_rel_path = 'template.txt' + self._assert_template_location(view, ('', 'template.txt')) + + def test_find_relative__template_rel_path__file_name_with_directory(self): + """ + Test _find_relative(): template_rel_path attribute. + + """ + view = SampleView() + view.template_rel_path = 'foo/bar/template.txt' + self._assert_template_location(view, ('foo/bar', 'template.txt')) + + def test_find_relative__template_rel_directory(self): + """ + Test _find_relative(): template_rel_directory attribute. + + """ + view = SampleView() + view.template_rel_directory = 'foo' + + self._assert_template_location(view, ('foo', 'sample_view.mustache')) + + def test_find_relative__template_name(self): + """ + Test _find_relative(): template_name attribute. + + """ + view = SampleView() + view.template_name = 'new_name' + self._assert_template_location(view, (None, 'new_name.mustache')) + + def test_find_relative__template_extension(self): + """ + Test _find_relative(): template_extension attribute. + + """ + view = SampleView() + view.template_extension = 'txt' + self._assert_template_location(view, (None, 'sample_view.txt')) + + def _assert_paths(self, actual, expected): + """ + Assert that two paths are the same. + + """ + self.assertEqual(actual, expected) + + def test_find__with_directory(self): + """ + Test _find() with a view that has a directory specified. + + """ + loader = self._make_loader() + + view = SampleView() + view.template_rel_path = 'foo/bar.txt' + self.assertTrue(loader._find_relative(view)[0] is not None) + + actual = loader._find(view) + expected = os.path.join(DATA_DIR, 'foo/bar.txt') + + self._assert_paths(actual, expected) + + def test_find__without_directory(self): + """ + Test _find() with a view that doesn't have a directory specified. + + """ + loader = self._make_loader() + + view = SampleView() + self.assertTrue(loader._find_relative(view)[0] is None) + + actual = loader._find(view) + expected = os.path.join(DATA_DIR, 'sample_view.mustache') + + self._assert_paths(actual, expected) + + def _assert_get_template(self, custom, expected): + loader = self._make_loader() + actual = loader.load(custom) + + self.assertEqual(type(actual), unicode) + self.assertEqual(actual, expected) + + def test_get_template(self): + """ + Test get_template(): default behavior (no attributes set). + + """ + view = SampleView() + + self._assert_get_template(view, u"ascii: abc") + + def test_get_template__template_encoding(self): + """ + Test get_template(): template_encoding attribute. + + """ + view = NonAscii() + + self.assertRaises(UnicodeDecodeError, self._assert_get_template, view, 'foo') + + view.template_encoding = 'utf-8' + self._assert_get_template(view, u"non-ascii: é") |