diff options
author | Chris Jerdonek <chris.jerdonek@gmail.com> | 2012-04-03 18:42:21 -0700 |
---|---|---|
committer | Chris Jerdonek <chris.jerdonek@gmail.com> | 2012-04-03 18:42:21 -0700 |
commit | a4d763d54d83d4cbc9c254c897165563b119f7e8 (patch) | |
tree | ee134c32eec215ac28dcb00d0748dcb0bb9887be | |
parent | a06fd4233d55ea317ea03b81c98b149e3d5b1364 (diff) | |
parent | 368f0dfd2f61b0e4a92d530e033eaec4a6fcfeb9 (diff) | |
download | pystache-a4d763d54d83d4cbc9c254c897165563b119f7e8.tar.gz |
Merge branch 'development' to 'master': staging version 0.5.0-rc.
68 files changed, 4603 insertions, 658 deletions
diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..c55c8e5 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "ext/spec"] + path = ext/spec + url = http://github.com/mustache/spec.git diff --git a/HISTORY.rst b/HISTORY.rst index 6e8f1a1..dcf8a99 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -1,6 +1,53 @@ History ======= +0.5.0 (2012-04-03) +------------------ + +This version represents a major rewrite and refactoring of the code base +that also adds features and fixes many bugs. All functionality and nearly +all unit tests have been preserved. However, some backwards incompatible +changes to the API have been made. + +Below is a selection of some of the changes (not exhaustive). + +Highlights: + +* Pystache now passes all tests in version 1.0.3 of the `Mustache spec`_. [pvande] +* Removed View class: it is no longer necessary to subclass from View or + from any other class to create a view. +* Replaced Template with Renderer class: template rendering behavior can be + modified via the Renderer constructor or by setting attributes on a Renderer instance. +* Added TemplateSpec class: template rendering can be specified on a per-view + basis by subclassing from TemplateSpec. +* Introduced separation of concerns and removed circular dependencies (e.g. + between Template and View classes, cf. `issue #13`_). +* Unicode now used consistently throughout the rendering process. +* Expanded test coverage: nosetests now runs doctests and ~105 test cases + from the Mustache spec (increasing the number of tests from 56 to ~315). +* Added a rudimentary benchmarking script to gauge performance while refactoring. +* Extensive documentation added (e.g. docstrings). + +Other changes: + +* Added a command-line interface. [vrde] +* The main rendering class now accepts a custom partial loader (e.g. a dictionary) + and a custom escape function. +* Non-ascii characters in str strings are now supported while rendering. +* Added string encoding, file encoding, and errors options for decoding to unicode. +* Removed the output encoding option. +* Removed the use of markupsafe. + +Bug fixes: + +* Context values no longer processed as template strings. [jakearchibald] +* Whitespace surrounding sections is no longer altered, per the spec. [heliodor] +* Zeroes now render correctly when using PyPy. [alex] +* Multline comments now permitted. [fczuardi] +* Extensionless template files are now supported. +* Passing ``**kwargs`` to ``Template()`` no longer modifies the context. +* Passing ``**kwargs`` to ``Template()`` with no context no longer raises an exception. + 0.4.1 (2012-03-25) ------------------ * Added support for Python 2.4. [wangtz, jvantuyl] @@ -44,3 +91,7 @@ History ------------------ * First release + + +.. _issue #13: https://github.com/defunkt/pystache/issues/13 +.. _Mustache spec: https://github.com/mustache/spec @@ -1,3 +1,4 @@ +Copyright (C) 2012 Chris Jerdonek. All rights reserved. Copyright (c) 2009 Chris Wanstrath Permission is hereby granted, free of charge, to any person obtaining @@ -4,27 +4,38 @@ Pystache .. image:: https://s3.amazonaws.com/webdev_bucket/pystache.png -Inspired by ctemplate_ and et_, Mustache_ is a -framework-agnostic way to render logic-free views. +Pystache_ is a Python implementation of Mustache_. +Mustache is a framework-agnostic, logic-free templating system inspired +by ctemplate_ and et_. Like ctemplate, Mustache "emphasizes +separating logic from presentation: it is impossible to embed application +logic in this template language." -As ctemplates says, "It emphasizes separating logic from presentation: -it is impossible to embed application logic in this template language." +The `mustache(5)`_ man page provides a good introduction to Mustache's +syntax. For a more complete (and more current) description of Mustache's +behavior, see the official `Mustache spec`_. -Pystache is a Python implementation of Mustache. Pystache works on-- +Pystache is `semantically versioned`_ and can be found on PyPI_. This +version of Pystache passes all tests in `version 1.0.3`_ of the spec. -* Python 2.4 -* Python 2.5 -* Python 2.6 -* Python 2.7 +Logo: `David Phillips`_ -Pystache is semantically versioned: http://semver.org. -Logo: David Phillips - http://davidphillips.us/ +Requirements +============ -Documentation -============= +Pystache is tested with the following versions of Python: + +* Python 2.4 (requires simplejson version 2.0.9 or earlier) +* Python 2.5 (requires simplejson) +* Python 2.6 +* Python 2.7 + +JSON support is needed only for the command-line interface and to run the +spec tests. Python's json_ module is new as of Python 2.6. Python's +simplejson_ package works with earlier versions of Python. Because +simplejson stopped officially supporting Python 2.4 as of version 2.1.0, +Python 2.4 requires an earlier version. -The different Mustache tags are documented at `mustache(5)`_. Install It ========== @@ -41,25 +52,64 @@ Use It >>> import pystache >>> pystache.render('Hi {{person}}!', {'person': 'Mom'}) - 'Hi Mom!' + u'Hi Mom!' You can also create dedicated view classes to hold your view logic. -Here's your simple.py:: +Here's your view class (in examples/readme.py):: + + class SayHello(object): - import pystache - class Simple(pystache.View): - def thing(self): - return "pizza" + def to(self): + return "Pizza" -Then your template, simple.mustache:: +Like so:: - Hi {{thing}}! + >>> from examples.readme import SayHello + >>> hello = SayHello() + +Then your template, say_hello.mustache:: + + Hello, {{to}}! Pull it together:: - >>> Simple().render() - 'Hi pizza!' + >>> renderer = pystache.Renderer() + >>> renderer.render(hello) + u'Hello, Pizza!' + + +Unicode Handling +================ + +This section describes Pystache's handling of unicode (e.g. strings and +encodings). + +Internally, Pystache uses `only unicode strings`_. For input, Pystache accepts +both ``unicode`` and ``str`` strings. For output, Pystache's template +rendering methods return only unicode. + +Pystache's ``Renderer`` class supports a number of attributes that control how +Pystache converts ``str`` strings to unicode on input. These include the +``file_encoding``, ``string_encoding``, and ``decode_errors`` attributes. + +The ``file_encoding`` attribute is the encoding the renderer uses to convert +to unicode any files read from the file system. Similarly, ``string_encoding`` +is the encoding the renderer uses to convert to unicode any other strings of +type ``str`` encountered during the rendering process (e.g. context values +of type ``str``). + +The ``decode_errors`` attribute is what the renderer passes as the ``errors`` +argument to Python's `built-in unicode function`_ ``unicode()`` when +converting. The valid values for this argument are ``strict``, ``ignore``, +and ``replace``. + +Each of these attributes can be set via the ``Renderer`` class's constructor +using a keyword argument of the same name. See the Renderer class's +docstrings for further details. In addition, the ``file_encoding`` +attribute can be controlled on a per-view basis by subclassing the +``TemplateSpec`` class. When not specified explicitly, these attributes +default to values set in Pystache's ``defaults`` module. Test It @@ -76,27 +126,57 @@ to type, for example :: nosetests-2.4 +To include tests from the Mustache spec in your test runs: :: + + git submodule init + git submodule update + +To run all available tests (including doctests):: + + nosetests --with-doctest --doctest-extension=rst + +or alternatively (using setup.cfg):: + + python setup.py nosetests + +To run a subset of the tests, you can use this pattern, for example: :: + + nosetests --tests tests/test_context.py:GetValueTests.test_dictionary__key_present + Mailing List -================== -As of Nov 26, 2011, there's a mailing list, pystache@librelist.com. +============ + +As of November 2011, there's a mailing list, pystache@librelist.com. Archive: http://librelist.com/browser/pystache/ Note: There's a bit of a delay in seeing the latest emails appear in the archive. + Author ====== :: - context = { 'author': 'Chris Wanstrath', 'email': 'chris@ozmm.org' } - pystache.render("{{author}} :: {{email}}", context) + >>> context = { 'author': 'Chris Wanstrath', 'email': 'chris@ozmm.org' } + >>> pystache.render("{{author}} :: {{email}}", context) + u'Chris Wanstrath :: chris@ozmm.org' .. _ctemplate: http://code.google.com/p/google-ctemplate/ +.. _David Phillips: http://davidphillips.us/ .. _et: http://www.ivan.fomichev.name/2008/05/erlang-template-engine-prototype.html -.. _Mustache: http://defunkt.github.com/mustache/ +.. _json: http://docs.python.org/library/json.html +.. _Mustache: http://mustache.github.com/ +.. _Mustache spec: https://github.com/mustache/spec .. _mustache(5): http://mustache.github.com/mustache.5.html -.. _nose: http://somethingaboutorange.com/mrl/projects/nose/0.11.1/testing.html
\ No newline at end of file +.. _nose: http://somethingaboutorange.com/mrl/projects/nose/0.11.1/testing.html +.. _only unicode strings: http://docs.python.org/howto/unicode.html#tips-for-writing-unicode-aware-programs +.. _PyPI: http://pypi.python.org/pypi/pystache +.. _Pystache: https://github.com/defunkt/pystache +.. _semantically versioned: http://semver.org +.. _simplejson: http://pypi.python.org/pypi/simplejson/ +.. _built-in unicode function: http://docs.python.org/library/functions.html#unicode +.. _version 1.0.3: https://github.com/mustache/spec/tree/48c933b0bb780875acbfd15816297e263c53d6f7 diff --git a/examples/comments.mustache b/examples/comments.mustache index 8bdfb5e..2a2a08b 100644 --- a/examples/comments.mustache +++ b/examples/comments.mustache @@ -1 +1 @@ -<h1>{{title}}{{! just something interesting... #or not... }}</h1> +<h1>{{title}}{{! just something interesting... #or not... }}</h1>
\ No newline at end of file diff --git a/examples/comments.py b/examples/comments.py index 1d2ed0b..f9c3125 100644 --- a/examples/comments.py +++ b/examples/comments.py @@ -1,7 +1,4 @@ -import pystache - -class Comments(pystache.View): - template_path = 'examples' +class Comments(object): def title(self): return "A Comedy of Errors" diff --git a/examples/complex.mustache b/examples/complex.mustache new file mode 100644 index 0000000..6de758b --- /dev/null +++ b/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/examples/complex_view.py b/examples/complex.py index ef45ff7..e3f1767 100644 --- a/examples/complex_view.py +++ b/examples/complex.py @@ -1,7 +1,4 @@ -import pystache - -class ComplexView(pystache.View): - template_path = 'examples' +class Complex(object): def header(self): return "Colors" @@ -18,6 +15,6 @@ class ComplexView(pystache.View): def empty(self): return len(self.item()) == 0 - + def empty_list(self): return []; diff --git a/examples/complex_view.mustache b/examples/complex_view.mustache deleted file mode 100644 index 45ea706..0000000 --- a/examples/complex_view.mustache +++ /dev/null @@ -1 +0,0 @@ -<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/examples/delimiters.py b/examples/delimiters.py index 53f65f2..a132ed0 100644 --- a/examples/delimiters.py +++ b/examples/delimiters.py @@ -1,7 +1,4 @@ -import pystache - -class Delimiters(pystache.View): - template_path = 'examples' +class Delimiters(object): def first(self): return "It worked the first time." diff --git a/examples/double_section.py b/examples/double_section.py index 7085739..0bec602 100644 --- a/examples/double_section.py +++ b/examples/double_section.py @@ -1,7 +1,4 @@ -import pystache - -class DoubleSection(pystache.View): - template_path = 'examples' +class DoubleSection(object): def t(self): return True diff --git a/examples/escaped.py b/examples/escaped.py index ddaad4f..fed1705 100644 --- a/examples/escaped.py +++ b/examples/escaped.py @@ -1,7 +1,4 @@ -import pystache - -class Escaped(pystache.View): - template_path = 'examples' +class Escaped(object): def title(self): return "Bear > Shark" diff --git a/examples/extensionless b/examples/extensionless new file mode 100644 index 0000000..452c9fe --- /dev/null +++ b/examples/extensionless @@ -0,0 +1 @@ +No file extension: {{foo}}
\ No newline at end of file diff --git a/examples/inner_partial.txt b/examples/inner_partial.txt index 3d1396e..650c959 100644 --- a/examples/inner_partial.txt +++ b/examples/inner_partial.txt @@ -1 +1 @@ -## Again, {{title}}! ## +## Again, {{title}}! ##
\ No newline at end of file diff --git a/examples/inverted.py b/examples/inverted.py index e0f7aaf..2a05302 100644 --- a/examples/inverted.py +++ b/examples/inverted.py @@ -1,7 +1,6 @@ -import pystache +from pystache import TemplateSpec -class Inverted(pystache.View): - template_path = 'examples' +class Inverted(object): def t(self): return True @@ -14,11 +13,11 @@ class Inverted(pystache.View): def empty_list(self): return [] - + def populated_list(self): return ['some_value'] -class InvertedLists(Inverted): +class InvertedLists(Inverted, TemplateSpec): template_name = 'inverted' def t(self): diff --git a/examples/lambdas.py b/examples/lambdas.py index 01a3697..653531d 100644 --- a/examples/lambdas.py +++ b/examples/lambdas.py @@ -1,4 +1,4 @@ -import pystache +from pystache import TemplateSpec def rot(s, n=13): r = "" @@ -17,8 +17,10 @@ def rot(s, n=13): def replace(subject, this='foo', with_this='bar'): return subject.replace(this, with_this) -class Lambdas(pystache.View): - template_path = 'examples' + +# 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 diff --git a/examples/nested_context.py b/examples/nested_context.py index 83565a1..4626ac0 100644 --- a/examples/nested_context.py +++ b/examples/nested_context.py @@ -1,7 +1,12 @@ -import pystache +from pystache import TemplateSpec -class NestedContext(pystache.View): - template_path = 'examples' +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" @@ -16,6 +21,6 @@ class NestedContext(pystache.View): return [{'outer': 'car'}] def nested_context_in_view(self): - if self.get('outer') == self.get('inner'): + if self._context_get('outer') == self._context_get('inner'): return 'it works!' - return ''
\ No newline at end of file + return '' diff --git a/examples/partials_with_lambdas.py b/examples/partials_with_lambdas.py index 4c3ee97..463a3ce 100644 --- a/examples/partials_with_lambdas.py +++ b/examples/partials_with_lambdas.py @@ -1,8 +1,6 @@ -import pystache from examples.lambdas import rot -class PartialsWithLambdas(pystache.View): - template_path = 'examples' - +class PartialsWithLambdas(object): + def rot(self): return rot
\ No newline at end of file diff --git a/examples/readme.py b/examples/readme.py new file mode 100644 index 0000000..23b44f5 --- /dev/null +++ b/examples/readme.py @@ -0,0 +1,3 @@ +class SayHello(object): + def to(self): + return "Pizza" diff --git a/examples/say_hello.mustache b/examples/say_hello.mustache new file mode 100644 index 0000000..7d8dfea --- /dev/null +++ b/examples/say_hello.mustache @@ -0,0 +1 @@ +Hello, {{to}}!
\ No newline at end of file diff --git a/examples/simple.py b/examples/simple.py index 792b243..3252a81 100644 --- a/examples/simple.py +++ b/examples/simple.py @@ -1,7 +1,6 @@ -import pystache +from pystache import TemplateSpec -class Simple(pystache.View): - template_path = 'examples' +class Simple(TemplateSpec): def thing(self): return "pizza" diff --git a/examples/tagless.mustache b/examples/tagless.mustache new file mode 100644 index 0000000..ad4dd31 --- /dev/null +++ b/examples/tagless.mustache @@ -0,0 +1 @@ +No tags...
\ No newline at end of file diff --git a/examples/template_partial.py b/examples/template_partial.py index 651cbe5..e96c83b 100644 --- a/examples/template_partial.py +++ b/examples/template_partial.py @@ -1,7 +1,12 @@ -import pystache +from pystache import TemplateSpec -class TemplatePartial(pystache.View): - template_path = 'examples' +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" @@ -11,6 +16,6 @@ class TemplatePartial(pystache.View): def looping(self): return [{'item': 'one'}, {'item': 'two'}, {'item': 'three'}] - + def thing(self): - return self['prop']
\ No newline at end of file + return self._context_get('prop')
\ No newline at end of file diff --git a/examples/unescaped.py b/examples/unescaped.py index a040dcb..67c12ca 100644 --- a/examples/unescaped.py +++ b/examples/unescaped.py @@ -1,7 +1,4 @@ -import pystache - -class Unescaped(pystache.View): - template_path = 'examples' +class Unescaped(object): def title(self): return "Bear > Shark" diff --git a/examples/unicode_input.mustache b/examples/unicode_input.mustache index 9b5e335..f654cd1 100644 --- a/examples/unicode_input.mustache +++ b/examples/unicode_input.mustache @@ -1 +1 @@ -<p>If alive today, Henri Poincaré would be {{age}} years old.</p>
\ No newline at end of file +abcdé
\ No newline at end of file diff --git a/examples/unicode_input.py b/examples/unicode_input.py index 9f3684f..2c10fcb 100644 --- a/examples/unicode_input.py +++ b/examples/unicode_input.py @@ -1,7 +1,7 @@ -import pystache +from pystache import TemplateSpec + +class UnicodeInput(TemplateSpec): -class UnicodeInput(pystache.View): - template_path = 'examples' template_encoding = 'utf8' def age(self): diff --git a/examples/unicode_output.py b/examples/unicode_output.py index 3cb9260..d5579c3 100644 --- a/examples/unicode_output.py +++ b/examples/unicode_output.py @@ -1,9 +1,6 @@ # encoding: utf-8 -import pystache - -class UnicodeOutput(pystache.View): - template_path = 'examples' +class UnicodeOutput(object): def name(self): return u'Henri Poincaré' diff --git a/ext/spec b/ext/spec new file mode 160000 +Subproject 48c933b0bb780875acbfd15816297e263c53d6f diff --git a/pystache/__init__.py b/pystache/__init__.py index 314c5c8..daf7f52 100644 --- a/pystache/__init__.py +++ b/pystache/__init__.py @@ -1,8 +1,2 @@ -from pystache.template import Template -from pystache.view import View -from pystache.loader import Loader - -def render(template, context=None, **kwargs): - context = context and context.copy() or {} - context.update(kwargs) - return Template(template, context).render() +# We keep all initialization code in a separate module. +from init import * diff --git a/pystache/commands.py b/pystache/commands.py new file mode 100644 index 0000000..1801d40 --- /dev/null +++ b/pystache/commands.py @@ -0,0 +1,81 @@ +# coding: utf-8 + +""" +This module provides command-line access to pystache. + +Run this script using the -h option for command-line help. + +""" + + +try: + import json +except: + # The json module is new in Python 2.6, whereas simplejson is + # compatible with earlier versions. + import simplejson as json + +# The optparse module is deprecated in Python 2.7 in favor of argparse. +# However, argparse is not available in Python 2.6 and earlier. +from optparse import OptionParser +import sys + +# We use absolute imports here to allow use of this script from its +# location in source control (e.g. for development purposes). +# Otherwise, the following error occurs: +# +# ValueError: Attempted relative import in non-package +# +from pystache.renderer import Renderer + + +USAGE = """\ +%prog [-h] template context + +Render a mustache template with the given context. + +positional arguments: + template A filename or template string. + context A filename or JSON string.""" + + +def parse_args(sys_argv, usage): + """ + Return an OptionParser for the script. + + """ + args = sys_argv[1:] + + parser = OptionParser(usage=usage) + options, args = parser.parse_args(args) + + template, context = args + + return template, context + + +def main(sys_argv): + template, context = parse_args(sys_argv, USAGE) + + if template.endswith('.mustache'): + template = template[:-9] + + renderer = Renderer() + + try: + template = renderer.load_template(template) + except IOError: + pass + + try: + context = json.load(open(context)) + except IOError: + context = json.loads(context) + + rendered = renderer.render(template, context) + print rendered + + +if __name__=='__main__': + main(sys.argv) + diff --git a/pystache/context.py b/pystache/context.py new file mode 100644 index 0000000..1621d61 --- /dev/null +++ b/pystache/context.py @@ -0,0 +1,264 @@ +# coding: utf-8 + +""" +Defines a Context class to represent mustache(5)'s notion of context. + +""" + +class NotFound(object): pass +# 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. +_NOT_FOUND = NotFound() + + +# TODO: share code with template.check_callable(). +def _is_callable(obj): + return hasattr(obj, '__call__') + + +def _get_value(item, key): + """ + Retrieve a key's value from an item. + + Returns _NOT_FOUND if the key does not exist. + + The Context.get() docstring documents this function's intended behavior. + + """ + if isinstance(item, dict): + # Then we consider the argument a "hash" for the purposes of the spec. + # + # We do a membership test to avoid using exceptions for flow control + # (e.g. catching KeyError). + if key in item: + return item[key] + elif type(item).__module__ != '__builtin__': + # Then we consider the argument an "object" for the purposes of + # the spec. + # + # The elif test above lets us avoid treating instances of built-in + # types like integers and strings as objects (cf. issue #81). + # Instances of user-defined classes on the other hand, for example, + # are considered objects by the test above. + if hasattr(item, key): + attr = getattr(item, key) + if _is_callable(attr): + return attr() + return attr + + return _NOT_FOUND + + +class Context(object): + + """ + Provides dictionary-like access to a stack of zero or more items. + + Instances of this class are meant to act as the rendering context + when rendering Mustache templates in accordance with mustache(5) + and the Mustache spec. + + Instances encapsulate a private stack of hashes, objects, and built-in + type instances. Querying the stack for the value of a key queries + the items in the stack in order from last-added objects to first + (last in, first out). + + Caution: this class does not currently support recursive nesting in + that items in the stack cannot themselves be Context instances. + + See the docstrings of the methods of this class for more details. + + """ + + # We reserve keyword arguments for future options (e.g. a "strict=True" + # option for enabling a strict mode). + def __init__(self, *items): + """ + Construct an instance, and initialize the private stack. + + The *items arguments are the items with which to populate the + initial stack. Items in the argument list are added to the + 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 + recursive nesting does not behave as one might expect. + + """ + self._stack = list(items) + + def __repr__(self): + """ + Return a string representation of the instance. + + For example-- + + >>> context = Context({'alpha': 'abc'}, {'numeric': 123}) + >>> repr(context) + "Context({'alpha': 'abc'}, {'numeric': 123})" + + """ + return "%s%s" % (self.__class__.__name__, tuple(self._stack)) + + @staticmethod + def create(*context, **kwargs): + """ + Build a Context instance from a sequence of context-like items. + + This factory-style method is more general than the Context class's + constructor in that, unlike the constructor, the argument list + can itself contain Context instances. + + Here is an example illustrating various aspects of this method: + + >>> obj1 = {'animal': 'cat', 'vegetable': 'carrot', 'mineral': 'copper'} + >>> obj2 = Context({'vegetable': 'spinach', 'mineral': 'silver'}) + >>> + >>> context = Context.create(obj1, None, obj2, mineral='gold') + >>> + >>> context.get('animal') + 'cat' + >>> context.get('vegetable') + 'spinach' + >>> context.get('mineral') + 'gold' + + Arguments: + + *context: zero or more dictionaries, Context 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 + list take precedence over earlier items. This behavior is the + same as the constructor's. + + **kwargs: additional key-value data to add to the context stack. + As these arguments appear after all items in the *context list, + in the case of key conflicts these values take precedence over + all items in the *context list. This behavior is the same as + the constructor's. + + """ + items = context + + context = Context() + + for item in items: + if item is None: + continue + if isinstance(item, Context): + context._stack.extend(item._stack) + else: + context.push(item) + + if kwargs: + context.push(kwargs) + + return context + + def get(self, key, default=None): + """ + Query the stack for the given key, and return the resulting value. + + This method queries items in the stack in order from last-added + objects to first (last in, first out). The value returned is + the value of the key in the first item that contains the key. + If the key is not found in any item in the stack, then the default + value is returned. The default value defaults to None. + + When speaking about returning values from a context, the Mustache + spec distinguishes between two types of context stack elements: + hashes and objects. + + In accordance with the spec, this method queries items in the + stack for a key in the following way. For the purposes of querying, + each item is classified into one of the following three mutually + exclusive categories: a hash, an object, or neither: + + (1) Hash: if the item's type is a subclass of dict, then the item + is considered a hash (in the terminology of the spec), and + the key's value is the dictionary value of the key. If the + dictionary doesn't contain the key, the key is not found. + + (2) Object: if the item isn't a hash and isn't an instance of a + built-in type, then the item is considered an object (again + using the language of the spec). In this case, the method + looks for an attribute with the same name as the key. If an + attribute with that name exists, the value of the attribute is + returned. If the attribute is callable, however (i.e. if the + attribute is a method), then the attribute is called with no + arguments and instead that value returned. If there is no + attribute with the same name as the key, then the key is + considered not found. + + (3) Neither: if the item is neither a hash nor an object, then + the key is considered not found. + + *Caution*: + + Callables are handled differently depending on whether they are + dictionary values, as in (1) above, or attributes, as in (2). + The former are returned as-is, while the latter are first + called and that value returned. + + Here is an example to illustrate: + + >>> def greet(): + ... return "Hi Bob!" + >>> + >>> class Greeter(object): + ... greet = None + >>> + >>> dct = {'greet': greet} + >>> obj = Greeter() + >>> obj.greet = greet + >>> + >>> dct['greet'] is obj.greet + True + >>> Context(dct).get('greet') #doctest: +ELLIPSIS + <function greet at 0x...> + >>> Context(obj).get('greet') + 'Hi Bob!' + + TODO: explain the rationale for this difference in treatment. + + """ + for obj in reversed(self._stack): + val = _get_value(obj, key) + if val is _NOT_FOUND: + continue + # Otherwise, the key was found. + return val + # Otherwise, no item in the stack contained the key. + + return default + + def push(self, item): + """ + Push an item onto the stack. + + """ + self._stack.append(item) + + def pop(self): + """ + Pop an item off of the stack, and return it. + + """ + return self._stack.pop() + + def top(self): + """ + Return the item last added to the stack. + + """ + return self._stack[-1] + + def copy(self): + """ + Return a copy of this instance. + + """ + return Context(*self._stack) diff --git a/pystache/defaults.py b/pystache/defaults.py new file mode 100644 index 0000000..b696410 --- /dev/null +++ b/pystache/defaults.py @@ -0,0 +1,50 @@ +# coding: utf-8 + +""" +This module provides a central location for defining default behavior. + +Throughout the package, these defaults take effect only when the user +does not otherwise specify a value. + +""" + +import cgi +import os +import sys + + +# How to handle encoding errors when decoding strings from str to unicode. +# +# This value is passed as the "errors" argument to Python's built-in +# unicode() function: +# +# http://docs.python.org/library/functions.html#unicode +# +DECODE_ERRORS = 'strict' + +# The name of the encoding to use when converting to unicode any strings of +# type str encountered during the rendering process. +STRING_ENCODING = sys.getdefaultencoding() + +# The name of the encoding to use when converting file contents to unicode. +# This default takes precedence over the STRING_ENCODING default for +# strings that arise from files. +FILE_ENCODING = sys.getdefaultencoding() + +# The starting list of directories in which to search for templates when +# loading a template by file name. +SEARCH_DIRS = [os.curdir] # i.e. ['.'] + +# The escape function to apply to strings that require escaping when +# 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: +# +# http://docs.python.org/library/cgi.html#cgi.escape +# +TAG_ESCAPE = lambda u: cgi.escape(u, quote=True) + +# The default template extension. +TEMPLATE_EXTENSION = 'mustache' diff --git a/pystache/init.py b/pystache/init.py new file mode 100644 index 0000000..b285a5c --- /dev/null +++ b/pystache/init.py @@ -0,0 +1,21 @@ +# encoding: utf-8 + +""" +This module contains the initialization logic called by __init__.py. + +""" + +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. + + """ + renderer = Renderer() + return renderer.render(template, context, **kwargs) diff --git a/pystache/loader.py b/pystache/loader.py index 63b7ee6..bcba71b 100644 --- a/pystache/loader.py +++ b/pystache/loader.py @@ -1,47 +1,155 @@ +# coding: utf-8 + +""" +This module provides a Loader class for locating and reading templates. + +""" + import os +import sys + +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. + + """ + if encoding is None: + encoding = defaults.STRING_ENCODING + return unicode(s, encoding, defaults.DECODE_ERRORS) + class Loader(object): - - template_extension = 'mustache' - template_path = '.' - template_encoding = None - - def load_template(self, template_name, template_dirs=None, encoding=None, extension=None): - '''Returns the template string from a file or throws IOError if it non existent''' - if None == template_dirs: - template_dirs = self.template_path - - if encoding is not None: - self.template_encoding = encoding - - if extension is not None: - self.template_extension = extension - - file_name = template_name + '.' + self.template_extension - - # Given a single directory we'll load from it - if isinstance(template_dirs, basestring): - file_path = os.path.join(template_dirs, file_name) - - return self._load_template_file(file_path) - - # Given a list of directories we'll check each for our file - for path in template_dirs: - file_path = os.path.join(path, file_name) - if os.path.exists(file_path): - return self._load_template_file(file_path) - - raise IOError('"%s" not found in "%s"' % (template_name, ':'.join(template_dirs),)) - - def _load_template_file(self, file_path): - '''Loads and returns the template file from disk''' - f = open(file_path, 'r') - + + """ + Loads the template associated to a name or user-defined object. + + """ + + def __init__(self, file_encoding=None, extension=None, to_unicode=None, + search_dirs=None): + """ + Construct a template loader instance. + + Arguments: + + extension: the template file extension. Pass False for no + extension (i.e. to use extensionless template files). + Defaults to the package default. + + file_encoding: the name of the encoding to use when converting file + contents to unicode. Defaults to the package default. + + search_dirs: the list of directories in which to search when loading + a template by name or file name. Defaults to the package default. + + to_unicode: the function to use when converting strings of type + str to unicode. The function should have the signature: + + to_unicode(s, encoding=None) + + It should accept a string of type str and an optional encoding + name and return a string of type unicode. Defaults to calling + Python's built-in function unicode() using the package string + encoding and decode errors defaults. + + """ + if extension is None: + extension = defaults.TEMPLATE_EXTENSION + + if file_encoding is None: + file_encoding = defaults.FILE_ENCODING + + if search_dirs is None: + search_dirs = defaults.SEARCH_DIRS + + if to_unicode is None: + to_unicode = _to_unicode + + self.extension = extension + self.file_encoding = file_encoding + # TODO: unit test setting this attribute. + self.search_dirs = search_dirs + self.to_unicode = to_unicode + + def _make_locator(self): + return Locator(extension=self.extension) + + def unicode(self, s, encoding=None): + """ + Convert a string to unicode using the given encoding, and return it. + + This function uses the underlying to_unicode attribute. + + Arguments: + + s: a basestring instance to convert to unicode. Unlike Python's + built-in unicode() function, it is okay to pass unicode strings + to this function. (Passing a unicode string to Python's unicode() + with the encoding argument throws the error, "TypeError: decoding + Unicode is not supported.") + + encoding: the encoding to pass to the to_unicode attribute. + Defaults to None. + + """ + if isinstance(s, unicode): + return unicode(s) + + return self.to_unicode(s, encoding) + + def read(self, path, encoding=None): + """ + 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: - template = f.read() - if self.template_encoding: - template = unicode(template, self.template_encoding) + text = f.read() finally: f.close() - - return template
\ No newline at end of file + + if encoding is None: + encoding = self.file_encoding + + return self.unicode(text, encoding) + + # TODO: unit-test this method. + def load_name(self, name): + """ + Find and return the template with the given name. + + Arguments: + + name: the name of the template. + + search_dirs: the list of directories in which to search. + + """ + locator = self._make_locator() + + path = locator.find_name(name, self.search_dirs) + + return self.read(path) + + # TODO: unit-test this method. + def load_object(self, obj): + """ + Find and return the template associated to the given object. + + Arguments: + + obj: an instance of a user-defined class. + + search_dirs: the list of directories in which to search. + + """ + locator = self._make_locator() + + path = locator.find_object(obj, self.search_dirs) + + return self.read(path) diff --git a/pystache/locator.py b/pystache/locator.py new file mode 100644 index 0000000..a1f06db --- /dev/null +++ b/pystache/locator.py @@ -0,0 +1,152 @@ +# coding: utf-8 + +""" +This module provides a Locator class for finding template files. + +""" + +import os +import re +import sys + +from pystache import defaults + + +class Locator(object): + + def __init__(self, extension=None): + """ + Construct a template locator. + + Arguments: + + extension: the template file extension. Pass False for no + extension (i.e. to use extensionless template files). + Defaults to the package default. + + """ + if extension is None: + extension = defaults.TEMPLATE_EXTENSION + + self.template_extension = extension + + def get_object_directory(self, obj): + """ + Return the directory containing an object's defining class. + + Returns None if there is no such directory, for example if the + class was defined in an interactive Python session, or in a + doctest that appears in a text file (rather than a Python file). + + """ + if not hasattr(obj, '__module__'): + return None + + module = sys.modules[obj.__module__] + + if not hasattr(module, '__file__'): + # TODO: add a unit test for this case. + return None + + path = module.__file__ + + return os.path.dirname(path) + + def make_template_name(self, obj): + """ + Return the canonical template name for an object instance. + + This method converts Python-style class names (PEP 8's recommended + CamelCase, aka CapWords) to lower_case_with_underscords. Here + is an example with code: + + >>> class HelloWorld(object): + ... pass + >>> hi = HelloWorld() + >>> + >>> locator = Locator() + >>> locator.make_template_name(hi) + 'hello_world' + + """ + template_name = obj.__class__.__name__ + + def repl(match): + return '_' + match.group(0).lower() + + return re.sub('[A-Z]', repl, template_name)[1:] + + def make_file_name(self, template_name, template_extension=None): + """ + Generate and return the file name for the given template name. + + Arguments: + + template_extension: defaults to the instance's extension. + + """ + file_name = template_name + + if template_extension is None: + template_extension = self.template_extension + + if template_extension is not False: + file_name += os.path.extsep + template_extension + + return file_name + + def _find_path(self, search_dirs, file_name): + """ + Search for the given file, and return the path. + + Returns None if the file is not found. + + """ + for dir_path in search_dirs: + file_path = os.path.join(dir_path, file_name) + if os.path.exists(file_path): + return file_path + + return None + + def _find_path_required(self, search_dirs, file_name): + """ + Return the path to a template with the given file name. + + """ + path = self._find_path(search_dirs, file_name) + + if path is None: + # TODO: we should probably raise an exception of our own type. + raise IOError('Template file %s not found in directories: %s' % + (repr(file_name), repr(search_dirs))) + + return path + + def find_name(self, template_name, search_dirs): + """ + Return the path to a template with the given name. + + """ + file_name = self.make_file_name(template_name) + + return self._find_path_required(search_dirs, file_name) + + def find_object(self, obj, search_dirs, file_name=None): + """ + Return the path to a template associated with the given object. + + """ + if file_name is None: + # TODO: should we define a make_file_name() method? + template_name = self.make_template_name(obj) + file_name = self.make_file_name(template_name) + + dir_path = self.get_object_directory(obj) + + if dir_path is not None: + search_dirs = [dir_path] + search_dirs + + path = self._find_path_required(search_dirs, file_name) + + return path diff --git a/pystache/parsed.py b/pystache/parsed.py new file mode 100644 index 0000000..5418ec1 --- /dev/null +++ b/pystache/parsed.py @@ -0,0 +1,49 @@ +# coding: utf-8 + +""" +Exposes a class that represents a parsed (or compiled) template. + +This module is meant only for internal use. + +""" + + +class ParsedTemplate(object): + + def __init__(self, parse_tree): + """ + Arguments: + + parse_tree: a list, each element of which is either-- + + (1) a unicode string, or + (2) a "rendering" callable that accepts a Context instance + and returns a unicode string. + + The possible rendering callables are the return values of the + following functions: + + * RenderEngine._make_get_escaped() + * RenderEngine._make_get_inverse() + * RenderEngine._make_get_literal() + * RenderEngine._make_get_partial() + * RenderEngine._make_get_section() + + """ + self._parse_tree = parse_tree + + def render(self, context): + """ + Returns: a string of type unicode. + + """ + # We avoid use of the ternary operator for Python 2.4 support. + def get_unicode(val): + if callable(val): + return val(context) + return val + parts = map(get_unicode, self._parse_tree) + s = ''.join(parts) + + return unicode(s) + diff --git a/pystache/parser.py b/pystache/parser.py new file mode 100644 index 0000000..d07ebf6 --- /dev/null +++ b/pystache/parser.py @@ -0,0 +1,197 @@ +# coding: utf-8 + +""" +Provides a class for parsing template strings. + +This module is only meant for internal use by the renderengine module. + +""" + +import re + +from parsed import ParsedTemplate + + +DEFAULT_DELIMITERS = ('{{', '}}') +END_OF_LINE_CHARACTERS = ['\r', '\n'] +NON_BLANK_RE = re.compile(r'^(.)', re.M) + + +def _compile_template_re(delimiters): + + # The possible tag type characters following the opening tag, + # excluding "=" and "{". + tag_types = "!>&/#^" + + # TODO: are we following this in the spec? + # + # The tag's content MUST be a non-whitespace character sequence + # NOT containing the current closing delimiter. + # + tag = r""" + (?P<whitespace>[\ \t]*) + %(otag)s \s* + (?: + (?P<change>=) \s* (?P<delims>.+?) \s* = | + (?P<raw>{) \s* (?P<raw_name>.+?) \s* } | + (?P<tag>[%(tag_types)s]?) \s* (?P<tag_key>[\s\S]+?) + ) + \s* %(ctag)s + """ % {'tag_types': tag_types, 'otag': re.escape(delimiters[0]), 'ctag': re.escape(delimiters[1])} + + return re.compile(tag, re.VERBOSE) + + +class ParsingError(Exception): + + pass + + +class Parser(object): + + _delimiters = None + _template_re = None + + def __init__(self, engine, delimiters=None): + """ + Construct an instance. + + Arguments: + + engine: a RenderEngine instance. + + """ + if delimiters is None: + delimiters = DEFAULT_DELIMITERS + + self._delimiters = delimiters + self.engine = engine + + def compile_template_re(self): + self._template_re = _compile_template_re(self._delimiters) + + def _change_delimiters(self, delimiters): + self._delimiters = delimiters + self.compile_template_re() + + def parse(self, template, index=0, section_key=None): + """ + Parse a template string into a ParsedTemplate instance. + + This method uses the current tag delimiter. + + Arguments: + + template: a template string of type unicode. + + """ + parse_tree = [] + start_index = index + + while True: + match = self._template_re.search(template, index) + + if match is None: + break + + match_index = match.start() + end_index = match.end() + + before_tag = template[index : match_index] + + parse_tree.append(before_tag) + + matches = match.groupdict() + + # Normalize the matches dictionary. + if matches['change'] is not None: + matches.update(tag='=', tag_key=matches['delims']) + elif matches['raw'] is not None: + matches.update(tag='&', tag_key=matches['raw_name']) + + tag_type = matches['tag'] + tag_key = matches['tag_key'] + leading_whitespace = matches['whitespace'] + + # Standalone (non-interpolation) tags consume the entire line, + # both leading whitespace and trailing newline. + did_tag_begin_line = match_index == 0 or template[match_index - 1] in END_OF_LINE_CHARACTERS + did_tag_end_line = end_index == len(template) or template[end_index] in END_OF_LINE_CHARACTERS + is_tag_interpolating = tag_type in ['', '&'] + + if did_tag_begin_line and did_tag_end_line and not is_tag_interpolating: + if end_index < len(template): + end_index += template[end_index] == '\r' and 1 or 0 + if end_index < len(template): + end_index += template[end_index] == '\n' and 1 or 0 + elif leading_whitespace: + parse_tree.append(leading_whitespace) + match_index += len(leading_whitespace) + leading_whitespace = '' + + if tag_type == '/': + if tag_key != section_key: + raise ParsingError("Section end tag mismatch: %s != %s" % (repr(tag_key), repr(section_key))) + + return ParsedTemplate(parse_tree), template[start_index:match_index], end_index + + index = self._handle_tag_type(template, parse_tree, tag_type, tag_key, leading_whitespace, end_index) + + # Save the rest of the template. + parse_tree.append(template[index:]) + + 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) + + return parsed_template, template, index_end + + def _handle_tag_type(self, template, parse_tree, tag_type, tag_key, leading_whitespace, end_index): + + # TODO: switch to using a dictionary instead of a bunch of ifs and elifs. + if tag_type == '!': + return end_index + + if tag_type == '=': + delimiters = tag_key.split() + self._change_delimiters(delimiters) + return end_index + + engine = self.engine + + if tag_type == '': + + func = engine._make_get_escaped(tag_key) + + elif tag_type == '&': + + func = engine._make_get_literal(tag_key) + + 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) + + elif tag_type == '^': + + parsed_section, template, end_index = self._parse_section(template, end_index, tag_key) + func = engine._make_get_inverse(tag_key, parsed_section) + + elif tag_type == '>': + + template = engine.load_partial(tag_key) + + # Indent before rendering. + template = re.sub(NON_BLANK_RE, leading_whitespace + r'\1', template) + + func = engine._make_get_partial(template) + + else: + + raise Exception("Unrecognized tag type: %s" % repr(tag_type)) + + parse_tree.append(func) + + return end_index + diff --git a/pystache/renderengine.py b/pystache/renderengine.py new file mode 100644 index 0000000..4361dca --- /dev/null +++ b/pystache/renderengine.py @@ -0,0 +1,236 @@ +# coding: utf-8 + +""" +Defines a class responsible for rendering logic. + +""" + +import re + +from parser import Parser + + +class RenderEngine(object): + + """ + Provides a render() method. + + This class is meant only for internal use. + + As a rule, the code in this class operates on unicode strings where + possible rather than, say, strings of type str or markupsafe.Markup. + This means that strings obtained from "external" sources like partials + and variable tag values are immediately converted to unicode (or + escaped and converted to unicode) before being operated on further. + This makes maintaining, reasoning about, and testing the correctness + of the code much simpler. In particular, it keeps the implementation + of this class independent of the API details of one (or possibly more) + unicode subclasses (e.g. markupsafe.Markup). + + """ + + def __init__(self, load_partial=None, literal=None, escape=None): + """ + Arguments: + + load_partial: the function to call when loading a partial. The + function should accept a string template name and return a + template string of type unicode (not a subclass). + + literal: the function used to convert unescaped variable tag + values to unicode, e.g. the value corresponding to a tag + "{{{name}}}". The function should accept a string of type + str or unicode (or a subclass) and return a string of type + unicode (but not a proper subclass of unicode). + This class will only pass basestring instances to this + function. For example, it will call str() on integer variable + values prior to passing them to this function. + + escape: the function used to escape and convert variable tag + values to unicode, e.g. the value corresponding to a tag + "{{name}}". The function should obey the same properties + described above for the "literal" function argument. + This function should take care to convert any str + arguments to unicode just as the literal function should, as + 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 + from plain unicode strings. + + """ + self.escape = escape + self.literal = literal + self.load_partial = load_partial + + def _get_string_value(self, context, tag_name): + """ + 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() + + if callable(val): + # According to the spec: + # + # When used as the data value for an Interpolation tag, + # the lambda MUST be treatable as an arity 0 function, + # and invoked as such. The returned value MUST be + # rendered against the default delimiters, then + # interpolated in place of the lambda. + template = val() + if not isinstance(template, basestring): + # In case the template is an integer, for example. + template = str(template) + if type(template) is not unicode: + template = self.literal(template) + val = self._render(template, context) + + if not isinstance(val, basestring): + val = str(val) + + return val + + def _make_get_literal(self, name): + def get_literal(context): + """ + Returns: a string of type unicode. + + """ + s = self._get_string_value(context, name) + s = self.literal(s) + return s + + return get_literal + + def _make_get_escaped(self, name): + get_literal = self._make_get_literal(name) + + def get_escaped(context): + """ + Returns: a string of type unicode. + + """ + s = self._get_string_value(context, name) + s = self.escape(s) + return s + + return get_escaped + + def _make_get_partial(self, template): + def get_partial(context): + """ + Returns: a string of type unicode. + + """ + return self._render(template, context) + + return get_partial + + def _make_get_inverse(self, name, parsed_template): + def get_inverse(context): + """ + Returns a string with type unicode. + + """ + data = context.get(name) + if data: + return u'' + return parsed_template.render(context) + + return get_inverse + + # TODO: the template_ and parsed_template_ arguments don't both seem + # to be necessary. Can we remove one of them? For example, if + # callable(data) is True, then the initial parsed_template isn't used. + def _make_get_section(self, name, parsed_template_, template_, delims): + def get_section(context): + """ + Returns: a string of type unicode. + + """ + template = template_ + parsed_template = parsed_template_ + data = context.get(name) + if not data: + data = [] + elif callable(data): + # TODO: should we check the arity? + template = data(template) + parsed_template = self._parse(template, delimiters=delims) + data = [ data ] + elif not hasattr(data, '__iter__') or isinstance(data, dict): + data = [ data ] + + parts = [] + for element in data: + context.push(element) + parts.append(parsed_template.render(context)) + context.pop() + + return unicode(''.join(parts)) + + return get_section + + def _parse(self, template, delimiters=None): + """ + Parse the given template, and return a ParsedTemplate instance. + + Arguments: + + template: a template string of type unicode. + + """ + parser = Parser(self, delimiters=delimiters) + parser.compile_template_re() + + return parser.parse(template=template) + + def _render(self, template, context): + """ + Returns: a string of type unicode. + + Arguments: + + template: a template string of type unicode. + context: a Context instance. + + """ + # We keep this type-check as an added check because this method is + # called with template strings coming from potentially externally- + # supplied functions like self.literal, self.load_partial, etc. + # Beyond this point, we have much better control over the type. + if type(template) is not unicode: + raise Exception("Argument 'template' not unicode: %s: %s" % (type(template), repr(template))) + + parsed_template = self._parse(template) + + return parsed_template.render(context) + + def render(self, template, context): + """ + Return a template rendered as a string with type unicode. + + Arguments: + + template: a template string of type unicode (but not a proper + subclass of unicode). + + context: a Context instance. + + """ + # Be strict but not too strict. In other words, accept str instead + # of unicode, but don't assume anything about the encoding (e.g. + # don't use self.literal). + template = unicode(template) + + return self._render(template, context) diff --git a/pystache/renderer.py b/pystache/renderer.py new file mode 100644 index 0000000..5bd2a3f --- /dev/null +++ b/pystache/renderer.py @@ -0,0 +1,336 @@ +# coding: utf-8 + +""" +This module provides a Renderer class to render templates. + +""" + +from pystache import defaults +from pystache.context import Context +from pystache.loader import Loader +from pystache.renderengine import RenderEngine +from pystache.spec_loader import SpecLoader +from pystache.template_spec import TemplateSpec + + +class Renderer(object): + + """ + A class for rendering mustache templates. + + This class supports several rendering options which are described in + the constructor's docstring. Among these, the constructor supports + passing a custom partial loader. + + Here is an example of rendering a template using a custom partial loader + that loads partials from a string-string dictionary. + + >>> partials = {'partial': 'Hello, {{thing}}!'} + >>> renderer = Renderer(partials=partials) + >>> renderer.render('{{>partial}}', {'thing': 'world'}) + u'Hello, world!' + + """ + + def __init__(self, file_encoding=None, string_encoding=None, + decode_errors=None, search_dirs=None, file_extension=None, + escape=None, partials=None): + """ + Construct an instance. + + Arguments: + + partials: an object (e.g. a dictionary) for custom partial loading + during the rendering process. + The object should have a get() method that accepts a string + and returns the corresponding template as a string, preferably + as a unicode string. If there is no template with that name, + the get() method should either return None (as dict.get() does) + or raise an exception. + If this argument is None, the rendering process will use + the normal procedure of locating and reading templates from + the file system -- using relevant instance attributes like + search_dirs, file_encoding, etc. + + decode_errors: the string to pass as the errors argument to the + built-in function unicode() when converting str strings to + unicode. Defaults to the package default. + + escape: the function used to escape variable tag values when + rendering a template. The function should accept a unicode + string (or subclass of unicode) and return an escaped string + that is again unicode (or a subclass of unicode). + This function need not handle strings of type `str` because + 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(). + + file_encoding: the name of the default encoding to use when reading + template files. All templates are converted to unicode prior + to parsing. This encoding is used when reading template files + and converting them to unicode. Defaults to the package default. + + file_extension: the template file extension. Pass False for no + extension (i.e. to use extensionless template files). + Defaults to the package default. + + search_dirs: the list of directories in which to search when + loading a template by name or file name. If given a string, + the method interprets the string as a single directory. + Defaults to the package default. + + string_encoding: the name of the encoding to use when converting + to unicode any strings of type str encountered during the + rendering process. The name will be passed as the encoding + argument to the built-in function unicode(). Defaults to the + package default. + + """ + if decode_errors is None: + decode_errors = defaults.DECODE_ERRORS + + if escape is None: + escape = defaults.TAG_ESCAPE + + if file_encoding is None: + file_encoding = defaults.FILE_ENCODING + + if file_extension is None: + file_extension = defaults.TEMPLATE_EXTENSION + + if search_dirs is None: + search_dirs = defaults.SEARCH_DIRS + + if string_encoding is None: + string_encoding = defaults.STRING_ENCODING + + if isinstance(search_dirs, basestring): + search_dirs = [search_dirs] + + self._context = None + self.decode_errors = decode_errors + self.escape = escape + self.file_encoding = file_encoding + self.file_extension = file_extension + self.partials = partials + self.search_dirs = search_dirs + self.string_encoding = string_encoding + + # This is an experimental way of giving views access to the current context. + # TODO: consider another approach of not giving access via a property, + # but instead letting the caller pass the initial context to the + # main render() method by reference. This approach would probably + # be less likely to be misused. + @property + def context(self): + """ + Return the current rendering context [experimental]. + + """ + return self._context + + def _to_unicode_soft(self, s): + """ + Convert a basestring to unicode, preserving any unicode subclass. + + """ + # We type-check to avoid "TypeError: decoding Unicode is not supported". + # We avoid the Python ternary operator for Python 2.4 support. + if isinstance(s, unicode): + return s + return self.unicode(s) + + def _to_unicode_hard(self, s): + """ + Convert a basestring to a string with type unicode (not subclass). + + """ + return unicode(self._to_unicode_soft(s)) + + def _escape_to_unicode(self, s): + """ + Convert a basestring to unicode (preserving any unicode subclass), and escape it. + + Returns a unicode string (not subclass). + + """ + return unicode(self.escape(self._to_unicode_soft(s))) + + def unicode(self, s, encoding=None): + """ + Convert a string to unicode, using string_encoding and decode_errors. + + Raises: + + TypeError: Because this method calls Python's built-in unicode() + function, this method raises the following exception if the + given string is already unicode: + + TypeError: decoding Unicode is not supported + + """ + if encoding is None: + encoding = self.string_encoding + + # TODO: Wrap UnicodeDecodeErrors with a message about setting + # the string_encoding and decode_errors attributes. + return unicode(s, encoding, self.decode_errors) + + def _make_loader(self): + """ + Create a Loader instance using current attributes. + + """ + return Loader(file_encoding=self.file_encoding, extension=self.file_extension, + to_unicode=self.unicode, search_dirs=self.search_dirs) + + def _make_load_template(self): + """ + Return a function that loads a template by name. + + """ + loader = self._make_loader() + + def load_template(template_name): + return loader.load_name(template_name) + + return load_template + + def _make_load_partial(self): + """ + Return the load_partial function to pass to RenderEngine.__init__(). + + """ + if self.partials is None: + load_template = self._make_load_template() + return load_template + + # Otherwise, create a load_partial function from the custom partial + # loader that satisfies RenderEngine requirements (and that provides + # a nicer exception, etc). + partials = self.partials + + def load_partial(name): + template = partials.get(name) + + if template is None: + # TODO: make a TemplateNotFoundException type that provides + # the original partials as an attribute. + raise Exception("Partial not found with name: %s" % repr(name)) + + # RenderEngine requires that the return value be unicode. + return self._to_unicode_hard(template) + + return load_partial + + def _make_render_engine(self): + """ + Return a RenderEngine instance for rendering. + + """ + load_partial = self._make_load_partial() + + engine = RenderEngine(load_partial=load_partial, + literal=self._to_unicode_hard, + escape=self._escape_to_unicode) + return engine + + # TODO: add unit tests for this method. + def load_template(self, template_name): + """ + Load a template by name from the file system. + + """ + load_template = self._make_load_template() + return load_template(template_name) + + def _render_string(self, template, *context, **kwargs): + """ + Render the given template string using the given context. + + """ + # RenderEngine.render() requires that the template string be unicode. + template = self._to_unicode_hard(template) + + context = Context.create(*context, **kwargs) + self._context = context + + engine = self._make_render_engine() + rendered = engine.render(template, context) + + return unicode(rendered) + + def _render_object(self, obj, *context, **kwargs): + """ + Render the template associated with the given object. + + """ + loader = self._make_loader() + + # TODO: consider an approach that does not require using an if + # block here. For example, perhaps this class's loader can be + # a SpecLoader in all cases, and the SpecLoader instance can + # check the object's type. Or perhaps Loader and SpecLoader + # can be refactored to implement the same interface. + if isinstance(obj, TemplateSpec): + loader = SpecLoader(loader) + template = loader.load(obj) + else: + template = loader.load_object(obj) + + context = [obj] + list(context) + + return self._render_string(template, *context, **kwargs) + + def render_path(self, template_path, *context, **kwargs): + """ + Render the template at the given path using the given context. + + Read the render() docstring for more information. + + """ + loader = self._make_loader() + template = loader.read(template_path) + + return self._render_string(template, *context, **kwargs) + + def render(self, template, *context, **kwargs): + """ + Render the given template (or template object) using the given context. + + Returns the rendering as a unicode string. + + Prior to rendering, templates of type str are converted to unicode + using the string_encoding and decode_errors attributes. See the + constructor docstring for more information. + + Arguments: + + template: a template string of type unicode or str, or an object + instance. If the argument is an object, the function first looks + for the template associated to the object by calling this class's + get_associated_template() method. The rendering process also + uses the passed object as the first element of the context stack + when rendering. + + *context: zero or more dictionaries, Context 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 + list take precedence over earlier items. + + **kwargs: additional key-value data to add to the context stack. + As these arguments appear after all items in the *context list, + in the case of key conflicts these values take precedence over + all items in the *context list. + + """ + if isinstance(template, basestring): + return self._render_string(template, *context, **kwargs) + # Otherwise, we assume the template is an object. + + return self._render_object(template, *context, **kwargs) diff --git a/pystache/spec_loader.py b/pystache/spec_loader.py new file mode 100644 index 0000000..3cb0f1a --- /dev/null +++ b/pystache/spec_loader.py @@ -0,0 +1,87 @@ +# coding: utf-8 + +""" +This module supports customized (aka special or specified) template loading. + +""" + +import os.path + +from pystache.loader import Loader + + +# TODO: add test cases for this class. +class SpecLoader(object): + + """ + Supports loading custom-specified templates (from TemplateSpec instances). + + """ + + def __init__(self, loader=None): + if loader is None: + loader = Loader() + + self.loader = loader + + def _find_relative(self, spec): + """ + Return the path to the template as a relative (dir, file_name) pair. + + The directory returned is relative to the directory containing the + class definition of the given object. The method returns None for + this directory if the directory is unknown without first searching + the search directories. + + """ + if spec.template_rel_path is not None: + return os.path.split(spec.template_rel_path) + # Otherwise, determine the file name separately. + + locator = self.loader._make_locator() + + # We do not use the ternary operator for Python 2.4 support. + if spec.template_name is not None: + template_name = spec.template_name + else: + template_name = locator.make_template_name(spec) + + file_name = locator.make_file_name(template_name, spec.template_extension) + + return (spec.template_rel_directory, file_name) + + def _find(self, spec): + """ + Find and return the path to the template associated to the instance. + + """ + dir_path, file_name = self._find_relative(spec) + + locator = self.loader._make_locator() + + if dir_path is None: + # Then we need to search for the path. + path = locator.find_object(spec, self.loader.search_dirs, file_name=file_name) + else: + obj_dir = locator.get_object_directory(spec) + path = os.path.join(obj_dir, dir_path, file_name) + + return path + + def load(self, spec): + """ + Find and return the template associated to a TemplateSpec instance. + + Returns the template as a unicode string. + + Arguments: + + spec: a TemplateSpec instance. + + """ + if spec.template is not None: + return self.loader.unicode(spec.template, spec.template_encoding) + + path = self._find(spec) + + return self.loader.read(path, spec.template_encoding) diff --git a/pystache/template.py b/pystache/template.py deleted file mode 100644 index f8219cb..0000000 --- a/pystache/template.py +++ /dev/null @@ -1,181 +0,0 @@ -import re -import cgi -import collections -import os -import copy - -try: - import markupsafe - escape = markupsafe.escape - literal = markupsafe.Markup - -except ImportError: - escape = lambda x: cgi.escape(unicode(x)) - literal = unicode - - -class Modifiers(dict): - """Dictionary with a decorator for assigning functions to keys.""" - - def set(self, key): - """ - Decorator function to set the given key to the decorated function. - - >>> modifiers = {} - >>> @modifiers.set('P') - ... def render_tongue(self, tag_name=None, context=None): - ... return ":P %s" % tag_name - >>> modifiers - {'P': <function render_tongue at 0x...>} - """ - - def setter(func): - self[key] = func - return func - return setter - - -class Template(object): - - tag_re = None - - otag = '{{' - - ctag = '}}' - - modifiers = Modifiers() - - def __init__(self, template=None, context=None, **kwargs): - from view import View - - self.template = template - - if kwargs: - context.update(kwargs) - - if isinstance(context, View): - self.view = context - else: - self.view = View(context=context) - - self._compile_regexps() - - def _compile_regexps(self): - tags = { - 'otag': re.escape(self.otag), - 'ctag': re.escape(self.ctag) - } - - section = r"%(otag)s[\#|^]([^\}]*)%(ctag)s\s*(.+?\s*)%(otag)s/\1%(ctag)s" - self.section_re = re.compile(section % tags, re.M|re.S) - - tag = r"%(otag)s(#|=|&|!|>|\{)?(.+?)\1?%(ctag)s+" - self.tag_re = re.compile(tag % tags) - - def _render_sections(self, template, view): - while True: - match = self.section_re.search(template) - if match is None: - break - - section, section_name, inner = match.group(0, 1, 2) - section_name = section_name.strip() - it = self.view.get(section_name, None) - replacer = '' - - # Callable - if it and callable(it): - replacer = it(inner) - # Dictionary - elif it and hasattr(it, 'keys') and hasattr(it, '__getitem__'): - if section[2] != '^': - replacer = self._render_dictionary(inner, it) - # Lists - elif it and hasattr(it, '__iter__'): - if section[2] != '^': - replacer = self._render_list(inner, it) - # Other objects - elif it and isinstance(it, object): - if section[2] != '^': - replacer = self._render_dictionary(inner, it) - # Falsey and Negated or Truthy and Not Negated - elif (not it and section[2] == '^') or (it and section[2] != '^'): - replacer = self._render_dictionary(inner, it) - - template = literal(template.replace(section, replacer)) - - return template - - def _render_tags(self, template): - while True: - match = self.tag_re.search(template) - if match is None: - break - - tag, tag_type, tag_name = match.group(0, 1, 2) - tag_name = tag_name.strip() - func = self.modifiers[tag_type] - replacement = func(self, tag_name) - template = template.replace(tag, replacement) - - return template - - def _render_dictionary(self, template, context): - self.view.context_list.insert(0, context) - template = Template(template, self.view) - out = template.render() - self.view.context_list.pop(0) - return out - - def _render_list(self, template, listing): - insides = [] - for item in listing: - insides.append(self._render_dictionary(template, item)) - - return ''.join(insides) - - @modifiers.set(None) - def _render_tag(self, tag_name): - raw = self.view.get(tag_name, '') - - # For methods with no return value - if not raw and raw is not 0: - if tag_name == '.': - raw = self.view.context_list[0] - else: - return '' - - return escape(raw) - - @modifiers.set('!') - def _render_comment(self, tag_name): - return '' - - @modifiers.set('>') - def _render_partial(self, template_name): - from pystache import Loader - markup = Loader().load_template(template_name, self.view.template_path, encoding=self.view.template_encoding) - template = Template(markup, self.view) - return template.render() - - @modifiers.set('=') - def _change_delimiter(self, tag_name): - """Changes the Mustache delimiter.""" - self.otag, self.ctag = tag_name.split(' ') - self._compile_regexps() - return '' - - @modifiers.set('{') - @modifiers.set('&') - def render_unescaped(self, tag_name): - """Render a tag without escaping it.""" - return literal(self.view.get(tag_name, '')) - - def render(self, encoding=None): - template = self._render_sections(self.template, self.view) - result = self._render_tags(template) - - if encoding is not None: - result = result.encode(encoding) - - return result diff --git a/pystache/template_spec.py b/pystache/template_spec.py new file mode 100644 index 0000000..c33f30b --- /dev/null +++ b/pystache/template_spec.py @@ -0,0 +1,43 @@ +# coding: utf-8 + +""" +This module supports customized (aka special or specified) template loading. + +""" + +# TODO: finish the class docstring. +class TemplateSpec(object): + + """ + A mixin or interface for specifying custom template information. + + The "spec" in TemplateSpec can be taken to mean that the template + information is either "specified" or "special." + + A view should subclass this class only if customized template loading + is needed. The following attributes allow one to customize/override + template information on a per view basis. A None value means to use + default behavior for that value and perform no customization. All + attributes are initialized to None. + + Attributes: + + template: the template as a string. + + template_rel_path: the path to the template file, relative to the + directory containing the module defining the class. + + template_rel_directory: the directory containing the template file, relative + to the directory containing the module defining the class. + + template_extension: the template file extension. Defaults to "mustache". + Pass False for no extension (i.e. extensionless template files). + + """ + + template = None + template_rel_path = None + template_rel_directory = None + template_name = None + template_extension = None + template_encoding = None diff --git a/pystache/view.py b/pystache/view.py deleted file mode 100644 index 925998e..0000000 --- a/pystache/view.py +++ /dev/null @@ -1,94 +0,0 @@ -from pystache import Template -import os.path -import re -from types import * - -def get_or_attr(context_list, name, default=None): - if not context_list: - return default - - for obj in context_list: - try: - return obj[name] - except KeyError: - pass - except: - try: - return getattr(obj, name) - except AttributeError: - pass - return default - -class View(object): - - template_name = None - template_path = None - template = None - template_encoding = None - template_extension = 'mustache' - - def __init__(self, template=None, context=None, **kwargs): - self.template = template - context = context or {} - context.update(**kwargs) - - self.context_list = [context] - - def get(self, attr, default=None): - attr = get_or_attr(self.context_list, attr, getattr(self, attr, default)) - if hasattr(attr, '__call__') and type(attr) is UnboundMethodType: - return attr() - else: - return attr - - def get_template(self, template_name): - if not self.template: - from pystache import Loader - template_name = self._get_template_name(template_name) - self.template = Loader().load_template(template_name, self.template_path, encoding=self.template_encoding, extension=self.template_extension) - - return self.template - - def _get_template_name(self, template_name=None): - """TemplatePartial => template_partial - Takes a string but defaults to using the current class' name or - the `template_name` attribute - """ - if template_name: - return template_name - - template_name = self.__class__.__name__ - - def repl(match): - return '_' + match.group(0).lower() - - return re.sub('[A-Z]', repl, template_name)[1:] - - def _get_context(self): - context = {} - for item in self.context_list: - if hasattr(item, 'keys') and hasattr(item, '__getitem__'): - context.update(item) - return context - - def render(self, encoding=None): - return Template(self.get_template(self.template_name), self).render(encoding=encoding) - - def __contains__(self, needle): - return needle in self.context or hasattr(self, needle) - - def __getitem__(self, attr): - val = self.get(attr, None) - - if not val and val is not 0: - raise KeyError("Key '%s' does not exist in View" % attr) - return val - - def __getattr__(self, attr): - if attr == 'context': - return self._get_context() - - raise AttributeError("Attribute '%s' does not exist in View" % attr) - - def __str__(self): - return self.render()
\ No newline at end of file diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..f91c44e --- /dev/null +++ b/setup.cfg @@ -0,0 +1,3 @@ +[nosetests] +with-doctest=1 +doctest-extension=rst @@ -1,44 +1,94 @@ #!/usr/bin/env python +# coding: utf-8 + """ -Run the following to publish to PyPI: +This script supports installing and distributing pystache. + +Below are instructions to pystache maintainers on how to push a new +version of pystache to PyPI-- + + http://pypi.python.org/pypi/pystache + +Create a PyPI user account. The user account will need permissions to push +to PyPI. A current "Package Index Owner" of pystache can grant you those +permissions. + +When you have permissions, run the following (after preparing the release, +bumping the version number in setup.py, etc): + + > python setup.py publish + +If you get an error like the following-- + + Upload failed (401): You must be identified to edit package information + +then add a file called .pyirc to your home directory with the following +contents: + + [server-login] + username: <PyPI username> + password: <PyPI password> + +as described here, for example: -> python setup.py publish + http://docs.python.org/release/2.5.2/dist/pypirc.html """ import os import sys + try: from setuptools import setup except ImportError: from distutils.core import setup + def publish(): - """Publish to Pypi""" - os.system("python setup.py sdist upload") + """ + Publish this package to PyPI (aka "the Cheeseshop"). + + """ + os.system('python setup.py sdist upload') + + +def make_long_description(): + """ + Return the long description for the package. -if sys.argv[-1] == "publish": + """ + long_description = open('README.rst').read() + '\n\n' + open('HISTORY.rst').read() + + return long_description + + +if sys.argv[-1] == 'publish': publish() sys.exit() +long_description = make_long_description() + setup(name='pystache', - version='0.4.1', + version='0.5.0-rc', description='Mustache for Python', - long_description=open('README.rst').read() + '\n\n' + open('HISTORY.rst').read(), + long_description=long_description, author='Chris Wanstrath', author_email='chris@ozmm.org', + maintainer='Chris Jerdonek', url='http://github.com/defunkt/pystache', packages=['pystache'], license='MIT', + entry_points = { + 'console_scripts': ['pystache=pystache.commands:main'], + }, classifiers = ( - "Development Status :: 4 - Beta", - "License :: OSI Approved :: MIT License", - "Programming Language :: Python", - "Programming Language :: Python :: 2.4", - "Programming Language :: Python :: 2.5", - "Programming Language :: Python :: 2.6", - "Programming Language :: Python :: 2.7", - ) - ) - + 'Development Status :: 4 - Beta', + 'License :: OSI Approved :: MIT License', + 'Programming Language :: Python', + 'Programming Language :: Python :: 2.4', + 'Programming Language :: Python :: 2.5', + 'Programming Language :: Python :: 2.6', + 'Programming Language :: Python :: 2.7', + ) +) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/tests/__init__.py diff --git a/tests/benchmark.py b/tests/benchmark.py new file mode 100755 index 0000000..d46e973 --- /dev/null +++ b/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/tests/common.py b/tests/common.py new file mode 100644 index 0000000..adc3ec2 --- /dev/null +++ b/tests/common.py @@ -0,0 +1,53 @@ +# coding: utf-8 + +""" +Provides test-related code that can be used by all tests. + +""" + +import os + +import examples + + +DATA_DIR = 'tests/data' +EXAMPLES_DIR = os.path.dirname(examples.__file__) + + +def get_data_path(file_name): + return os.path.join(DATA_DIR, file_name) + + +class AssertStringMixin: + + """A unittest.TestCase mixin to check string equality.""" + + def assertString(self, actual, expected): + """ + Assert that the given strings are equal and have the same type. + + """ + # Show both friendly and literal versions. + message = """String mismatch: %%s\ + + + Expected: \"""%s\""" + Actual: \"""%s\""" + + Expected: %s + Actual: %s""" % (expected, actual, repr(expected), repr(actual)) + + self.assertEquals(actual, expected, message % "different characters") + + details = "types different: %s != %s" % (repr(type(expected)), repr(type(actual))) + self.assertEquals(type(expected), type(actual), message % details) + + +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))) diff --git a/tests/data/__init__.py b/tests/data/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/tests/data/__init__.py diff --git a/tests/data/ascii.mustache b/tests/data/ascii.mustache new file mode 100644 index 0000000..e86737b --- /dev/null +++ b/tests/data/ascii.mustache @@ -0,0 +1 @@ +ascii: abc
\ No newline at end of file diff --git a/tests/data/duplicate.mustache b/tests/data/duplicate.mustache new file mode 100644 index 0000000..a0515e3 --- /dev/null +++ b/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/tests/data/locator/duplicate.mustache b/tests/data/locator/duplicate.mustache new file mode 100644 index 0000000..a0515e3 --- /dev/null +++ b/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/tests/data/non_ascii.mustache b/tests/data/non_ascii.mustache new file mode 100644 index 0000000..bd69b61 --- /dev/null +++ b/tests/data/non_ascii.mustache @@ -0,0 +1 @@ +non-ascii: é
\ No newline at end of file diff --git a/tests/data/sample_view.mustache b/tests/data/sample_view.mustache new file mode 100644 index 0000000..e86737b --- /dev/null +++ b/tests/data/sample_view.mustache @@ -0,0 +1 @@ +ascii: abc
\ No newline at end of file diff --git a/tests/data/say_hello.mustache b/tests/data/say_hello.mustache new file mode 100644 index 0000000..84ab4c9 --- /dev/null +++ b/tests/data/say_hello.mustache @@ -0,0 +1 @@ +Hello, {{to}}
\ No newline at end of file diff --git a/tests/data/views.py b/tests/data/views.py new file mode 100644 index 0000000..4d9df02 --- /dev/null +++ b/tests/data/views.py @@ -0,0 +1,16 @@ +# coding: utf-8 + +from pystache import TemplateSpec + +class SayHello(object): + + def to(self): + return "World" + + +class SampleView(TemplateSpec): + pass + + +class NonAscii(TemplateSpec): + pass diff --git a/tests/test_commands.py b/tests/test_commands.py new file mode 100644 index 0000000..f1817e7 --- /dev/null +++ b/tests/test_commands.py @@ -0,0 +1,45 @@ +# coding: utf-8 + +""" +Unit tests of commands.py. + +""" + +import sys +import unittest + +from pystache.commands 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.assertEquals(actual, u"Hi world\n") + + def tearDown(self): + sys.stdout = ORIGINAL_STDOUT diff --git a/tests/test_context.py b/tests/test_context.py new file mode 100644 index 0000000..decf4fb --- /dev/null +++ b/tests/test_context.py @@ -0,0 +1,400 @@ +# 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 Context +from tests.common import AssertIsMixin + +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.assertEquals(_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.assertNotEquals(_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 = {} + attr_name = "keys" + self.assertEquals(getattr(item, attr_name)(), []) + 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.assertEquals(_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.assertEquals(_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.assertEquals(_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.assertEquals(_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.assertEquals(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 + + item1 = MyInt(10) + item2 = 10 + + try: + item2.real + except AttributeError: + # Then skip this unit test. The numeric type hierarchy was + # added only in Python 2.6, in which case integers inherit + # from complex numbers the "real" attribute, etc: + # + # http://docs.python.org/library/numbers.html + # + return + + self.assertEquals(item1.real, 10) + self.assertEquals(item2.real, 10) + + self.assertEquals(_get_value(item1, 'real'), 10) + self.assertNotFound(item2, 'real') + + def test_built_in_type__string(self): + """ + Test getting from a string. + + """ + class MyStr(str): pass + + item1 = MyStr('abc') + item2 = 'abc' + + self.assertEquals(item1.upper(), 'ABC') + self.assertEquals(item2.upper(), 'ABC') + + self.assertEquals(_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.assertEquals(item1.pop(), 3) + self.assertEquals(item2.pop(), 3) + + self.assertEquals(_get_value(item1, 'pop'), 2) + self.assertNotFound(item2, 'pop') + + +class ContextTests(unittest.TestCase, AssertIsMixin): + + """ + Test the Context class. + + """ + + def test_init__no_elements(self): + """ + Check that passing nothing to __init__() raises no exception. + + """ + context = Context() + + def test_init__many_elements(self): + """ + Check that passing more than two items to __init__() raises no exception. + + """ + context = Context({}, {}, {}) + + def test__repr(self): + context = Context() + self.assertEquals(repr(context), 'Context()') + + context = Context({'foo': 'bar'}) + self.assertEquals(repr(context), "Context({'foo': 'bar'},)") + + context = Context({'foo': 'bar'}, {'abc': 123}) + self.assertEquals(repr(context), "Context({'foo': 'bar'}, {'abc': 123})") + + def test__str(self): + context = Context() + self.assertEquals(str(context), 'Context()') + + context = Context({'foo': 'bar'}) + self.assertEquals(str(context), "Context({'foo': 'bar'},)") + + context = Context({'foo': 'bar'}, {'abc': 123}) + self.assertEquals(str(context), "Context({'foo': 'bar'}, {'abc': 123})") + + ## Test the static create() method. + + def test_create__dictionary(self): + """ + Test passing a dictionary. + + """ + context = Context.create({'foo': 'bar'}) + self.assertEquals(context.get('foo'), 'bar') + + def test_create__none(self): + """ + Test passing None. + + """ + context = Context.create({'foo': 'bar'}, None) + self.assertEquals(context.get('foo'), 'bar') + + def test_create__object(self): + """ + Test passing an object. + + """ + class Foo(object): + foo = 'bar' + context = Context.create(Foo()) + self.assertEquals(context.get('foo'), 'bar') + + def test_create__context(self): + """ + Test passing a Context instance. + + """ + obj = Context({'foo': 'bar'}) + context = Context.create(obj) + self.assertEquals(context.get('foo'), 'bar') + + def test_create__kwarg(self): + """ + Test passing a keyword argument. + + """ + context = Context.create(foo='bar') + self.assertEquals(context.get('foo'), 'bar') + + def test_create__precedence_positional(self): + """ + Test precedence of positional arguments. + + """ + context = Context.create({'foo': 'bar'}, {'foo': 'buzz'}) + self.assertEquals(context.get('foo'), 'buzz') + + def test_create__precedence_keyword(self): + """ + Test precedence of keyword arguments. + + """ + context = Context.create({'foo': 'bar'}, foo='buzz') + self.assertEquals(context.get('foo'), 'buzz') + + def test_get__key_present(self): + """ + Test getting a key. + + """ + context = Context({"foo": "bar"}) + self.assertEquals(context.get("foo"), "bar") + + def test_get__key_missing(self): + """ + Test getting a missing key. + + """ + context = Context() + self.assertTrue(context.get("foo") is None) + + def test_get__default(self): + """ + Test that get() respects the default value. + + """ + context = Context() + self.assertEquals(context.get("foo", "bar"), "bar") + + def test_get__precedence(self): + """ + Test that get() respects the order of precedence (later items first). + + """ + context = Context({"foo": "bar"}, {"foo": "buzz"}) + self.assertEquals(context.get("foo"), "buzz") + + def test_get__fallback(self): + """ + Check that first-added stack items are queried on context misses. + + """ + context = Context({"fuzz": "buzz"}, {"foo": "bar"}) + self.assertEquals(context.get("fuzz"), "buzz") + + def test_push(self): + """ + Test push(). + + """ + key = "foo" + context = Context({key: "bar"}) + self.assertEquals(context.get(key), "bar") + + context.push({key: "buzz"}) + self.assertEquals(context.get(key), "buzz") + + def test_pop(self): + """ + Test pop(). + + """ + key = "foo" + context = Context({key: "bar"}, {key: "buzz"}) + self.assertEquals(context.get(key), "buzz") + + item = context.pop() + self.assertEquals(item, {"foo": "buzz"}) + self.assertEquals(context.get(key), "bar") + + def test_top(self): + key = "foo" + context = Context({key: "bar"}, {key: "buzz"}) + self.assertEquals(context.get(key), "buzz") + + top = context.top() + self.assertEquals(top, {"foo": "buzz"}) + # Make sure calling top() didn't remove the item from the stack. + self.assertEquals(context.get(key), "buzz") + + def test_copy(self): + key = "foo" + original = Context({key: "bar"}, {key: "buzz"}) + self.assertEquals(original.get(key), "buzz") + + new = original.copy() + # Confirm that the copy behaves the same. + self.assertEquals(new.get(key), "buzz") + # Change the copy, and confirm it is changed. + new.pop() + self.assertEquals(new.get(key), "bar") + # Confirm the original is unchanged. + self.assertEquals(original.get(key), "buzz") + diff --git a/tests/test_examples.py b/tests/test_examples.py index 1e861bd..179b089 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -1,7 +1,6 @@ # encoding: utf-8 import unittest -import pystache from examples.comments import Comments from examples.double_section import DoubleSection @@ -12,72 +11,91 @@ 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 tests.common import EXAMPLES_DIR +from tests.common import AssertStringMixin + + +class TestView(unittest.TestCase, AssertStringMixin): + + def _assert(self, obj, expected): + renderer = Renderer() + actual = renderer.render(obj) + self.assertString(actual, expected) -class TestView(unittest.TestCase): def test_comments(self): - self.assertEquals(Comments().render(), """<h1>A Comedy of Errors</h1> -""") + self._assert(Comments(), u"<h1>A Comedy of Errors</h1>") def test_double_section(self): - self.assertEquals(DoubleSection().render(),"""* first\n* second\n* third""") + self._assert(DoubleSection(), u"* first\n* second\n* third") def test_unicode_output(self): - self.assertEquals(UnicodeOutput().render(), u'<p>Name: Henri Poincaré</p>') - - def test_encoded_output(self): - self.assertEquals(UnicodeOutput().render('utf8'), '<p>Name: Henri Poincar\xc3\xa9</p>') + renderer = Renderer() + actual = renderer.render(UnicodeOutput()) + self.assertString(actual, u'<p>Name: Henri Poincaré</p>') def test_unicode_input(self): - self.assertEquals(UnicodeInput().render(), - u'<p>If alive today, Henri Poincaré would be 156 years old.</p>') + renderer = Renderer() + actual = renderer.render(UnicodeInput()) + self.assertString(actual, u'abcdé') - def test_escaped(self): - self.assertEquals(Escaped().render(), "<h1>Bear > Shark</h1>") + def test_escaping(self): + self._assert(Escaped(), u"<h1>Bear > Shark</h1>") - def test_unescaped(self): - self.assertEquals(Unescaped().render(), "<h1>Bear > Shark</h1>") - - def test_unescaped_sigil(self): - view = Escaped(template="<h1>{{& thing}}</h1>", context={ - 'thing': 'Bear > Giraffe' - }) - self.assertEquals(view.render(), "<h1>Bear > Giraffe</h1>") + def test_literal(self): + renderer = Renderer() + actual = renderer.render(Unescaped()) + self.assertString(actual, u"<h1>Bear > Shark</h1>") def test_template_partial(self): - self.assertEquals(TemplatePartial().render(), """<h1>Welcome</h1> + 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): - view = TemplatePartial() - view.template_extension = 'txt' - self.assertEquals(view.render(), """Welcome -------- + renderer = Renderer(search_dirs=EXAMPLES_DIR, file_extension='txt') -Again, Welcome! -""") + view = TemplatePartial(renderer=renderer) + actual = renderer.render(view) + self.assertString(actual, u"""Welcome +------- + +## Again, Welcome! ##""") def test_delimiters(self): - self.assertEquals(Delimiters().render(), """ + 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): - self.assertEquals(NestedContext().render(), "one and foo and two") + 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): - view = NestedContext() + renderer = Renderer() + + view = NestedContext(renderer) view.template = '{{#herp}}{{#derp}}{{nested_context_in_view}}{{/derp}}{{/herp}}' - self.assertEquals(view.render(), 'it works!') + + actual = renderer.render(view) + self.assertString(actual, u'it works!') def test_partial_in_partial_has_access_to_grand_parent_context(self): - view = TemplatePartial(context = {'prop': 'derp'}) + renderer = Renderer(search_dirs=EXAMPLES_DIR) + + view = TemplatePartial(renderer=renderer) view.template = '''{{>partial_in_partial}}''' - self.assertEquals(view.render(), 'Hi derp!') + + actual = renderer.render(view, {'prop': 'derp'}) + self.assertEquals(actual, 'Hi derp!') if __name__ == '__main__': unittest.main() diff --git a/tests/test_loader.py b/tests/test_loader.py index 42222ac..119ebef 100644 --- a/tests/test_loader.py +++ b/tests/test_loader.py @@ -1,21 +1,198 @@ +# encoding: utf-8 + +""" +Unit tests of reader.py. + +""" + +import os +import sys import unittest -import pystache - -class TestLoader(unittest.TestCase): - - def test_template_is_loaded(self): - loader = pystache.Loader() - template = loader.load_template('simple', 'examples') - - self.assertEqual(template, 'Hi {{thing}}!{{blank}}') - - def test_using_list_of_paths(self): - loader = pystache.Loader() - template = loader.load_template('simple', ['doesnt_exist', 'examples']) - - self.assertEqual(template, 'Hi {{thing}}!{{blank}}') - - def test_non_existent_template_fails(self): - loader = pystache.Loader() - - self.assertRaises(IOError, loader.load_template, 'simple', 'doesnt_exist')
\ No newline at end of file + +from tests.common import AssertStringMixin +from pystache import defaults +from pystache.loader import Loader + + +DATA_DIR = 'tests/data' + + +class LoaderTests(unittest.TestCase, AssertStringMixin): + + def test_init__extension(self): + loader = Loader(extension='foo') + self.assertEquals(loader.extension, 'foo') + + def test_init__extension__default(self): + # Test the default value. + loader = Loader() + self.assertEquals(loader.extension, 'mustache') + + def test_init__file_encoding(self): + loader = Loader(file_encoding='bar') + self.assertEquals(loader.file_encoding, 'bar') + + def test_init__file_encoding__default(self): + file_encoding = defaults.FILE_ENCODING + try: + defaults.FILE_ENCODING = 'foo' + loader = Loader() + self.assertEquals(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.assertEquals(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 = 'abcdé' + + try: + defaults.DECODE_ERRORS = 'strict' + defaults.STRING_ENCODING = 'ascii' + 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é') + + finally: + defaults.DECODE_ERRORS = decode_errors + defaults.STRING_ENCODING = string_encoding + + 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. + + """ + reader = Loader() + actual = reader.unicode("foo") + + self.assertString(actual, u"foo") + + def test_unicode__basic__input_unicode(self): + """ + Test unicode(): default arguments with unicode input. + + """ + reader = Loader() + actual = reader.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") + + reader = Loader() + actual = reader.unicode(s) + + self.assertString(actual, u"foo") + + def test_unicode__to_unicode__attribute(self): + """ + Test unicode(): encoding attribute. + + """ + reader = Loader() + + non_ascii = u'abcdé'.encode('utf-8') + + self.assertRaises(UnicodeDecodeError, reader.unicode, non_ascii) + + def to_unicode(s, encoding=None): + if encoding is None: + encoding = 'utf-8' + return unicode(s, encoding) + + reader.to_unicode = to_unicode + self.assertString(reader.unicode(non_ascii), u"abcdé") + + def test_unicode__encoding_argument(self): + """ + Test unicode(): encoding argument. + + """ + reader = Loader() + + non_ascii = u'abcdé'.encode('utf-8') + + self.assertRaises(UnicodeDecodeError, reader.unicode, non_ascii) + + actual = reader.unicode(non_ascii, encoding='utf-8') + self.assertString(actual, u'abcdé') + + # TODO: check the read() unit tests. + def test_read(self): + """ + Test read(). + + """ + reader = Loader() + path = self._get_path('ascii.mustache') + actual = reader.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. + + """ + reader = Loader() + path = self._get_path('non_ascii.mustache') + + self.assertRaises(UnicodeDecodeError, reader.read, path) + + actual = reader.read(path, encoding='utf-8') + self.assertString(actual, u'non-ascii: é') + + def test_reader__to_unicode__attribute(self): + """ + Test read(): to_unicode attribute respected. + + """ + reader = Loader() + path = self._get_path('non_ascii.mustache') + + self.assertRaises(UnicodeDecodeError, reader.read, path) + + #reader.decode_errors = 'ignore' + #actual = reader.read(path) + #self.assertString(actual, u'non-ascii: ') + diff --git a/tests/test_locator.py b/tests/test_locator.py new file mode 100644 index 0000000..94a55ad --- /dev/null +++ b/tests/test_locator.py @@ -0,0 +1,150 @@ +# encoding: utf-8 + +""" +Contains locator.py unit tests. + +""" + +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 tests.common import DATA_DIR +from 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.assertEquals(locator.template_extension, 'mustache') + + locator = Locator(extension='txt') + self.assertEquals(locator.template_extension, 'txt') + + locator = Locator(extension=False) + self.assertTrue(locator.template_extension is False) + + def test_get_object_directory(self): + locator = Locator() + + obj = SayHello() + actual = locator.get_object_directory(obj) + + self.assertEquals(actual, os.path.abspath(DATA_DIR)) + + def test_get_object_directory__not_hasattr_module(self): + locator = Locator() + + obj = datetime(2000, 1, 1) + self.assertFalse(hasattr(obj, '__module__')) + self.assertEquals(locator.get_object_directory(obj), None) + + self.assertFalse(hasattr(None, '__module__')) + self.assertEquals(locator.get_object_directory(None), None) + + def test_make_file_name(self): + locator = Locator() + + locator.template_extension = 'bar' + self.assertEquals(locator.make_file_name('foo'), 'foo.bar') + + locator.template_extension = False + self.assertEquals(locator.make_file_name('foo'), 'foo') + + locator.template_extension = '' + self.assertEquals(locator.make_file_name('foo'), 'foo.') + + def test_make_file_name__template_extension_argument(self): + locator = Locator() + + self.assertEquals(locator.make_file_name('foo', template_extension='bar'), 'foo.bar') + + def test_find_name(self): + locator = Locator() + path = locator.find_name(search_dirs=['examples'], template_name='simple') + + self.assertEquals(os.path.basename(path), 'simple.mustache') + + def test_find_name__using_list_of_paths(self): + locator = Locator() + path = locator.find_name(search_dirs=['doesnt_exist', 'examples'], 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.assertEquals(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.abspath(os.path.join(DATA_DIR, 'sample_view.mustache')) + + self.assertEquals(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.abspath(os.path.join(DATA_DIR, 'say_hello.mustache')) + + self.assertEquals(actual, expected) + + def test_find_object__none_object_directory(self): + locator = Locator() + + obj = None + self.assertEquals(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.assertEquals(actual, expected) + + def test_make_template_name(self): + """ + Test make_template_name(). + + """ + locator = Locator() + + class FooBar(object): + pass + foo = FooBar() + + self.assertEquals(locator.make_template_name(foo), 'foo_bar') diff --git a/tests/test_pystache.py b/tests/test_pystache.py index c04489b..f9857cd 100644 --- a/tests/test_pystache.py +++ b/tests/test_pystache.py @@ -2,8 +2,15 @@ import unittest import pystache +from pystache import renderer + + +class PystacheTests(unittest.TestCase): + + def _assert_rendered(self, expected, template, context): + actual = pystache.render(template, context) + self.assertEquals(actual, expected) -class TestPystache(unittest.TestCase): def test_basic(self): ret = pystache.render("Hi {{thing}}!", { 'thing': 'world' }) self.assertEquals(ret, "Hi world!") @@ -14,66 +21,96 @@ class TestPystache(unittest.TestCase): def test_less_basic(self): template = "It's a nice day for {{beverage}}, right {{person}}?" - ret = pystache.render(template, { 'beverage': 'soda', 'person': 'Bob' }) - self.assertEquals(ret, "It's a nice day for soda, right Bob?") + 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}}?" - ret = pystache.render(template, { 'name': 'Jon', 'thing': 'racecar' }) - self.assertEquals(ret, "I think Jon wants a racecar, right Jon?") + 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}}?" - ret = pystache.render(template, { 'name': 'Jon' }) - self.assertEquals(ret, "I think Jon wants a , right Jon?") + 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}}.' - ret = pystache.render(template, { 'value': 0 }) - self.assertEquals(ret, 'My value is 0.') + context = { 'value': 0 } + self._assert_rendered('My value is 0.', template, context) def test_comments(self): template = "What {{! the }} what?" - ret = pystache.render(template) - self.assertEquals(ret, "What what?") + actual = pystache.render(template) + self.assertEquals("What what?", actual) def test_false_sections_are_hidden(self): template = "Ready {{#set}}set {{/set}}go!" - ret = pystache.render(template, { 'set': False }) - self.assertEquals(ret, "Ready go!") + context = { 'set': False } + self._assert_rendered("Ready go!", template, context) def test_true_sections_are_shown(self): template = "Ready {{#set}}set{{/set}} go!" - ret = pystache.render(template, { 'set': True }) - self.assertEquals(ret, "Ready 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}) - - ret = pystache.render(template, { 'stats': stats }) - self.assertEquals(ret, """(123 & ['something'])(chris & 0.9)""") + context = { 'stats': stats } + self._assert_rendered(self.non_strings_expected, template, context) def test_unicode(self): template = 'Name: {{name}}; Age: {{age}}' - ret = pystache.render(template, { 'name': u'Henri Poincaré', - 'age': 156 }) - self.assertEquals(ret, u'Name: Henri Poincaré; Age: 156') + 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'} ] } - ret = pystache.render(template, context) - self.assertEquals(ret, """<ul><li>Chris</li><li>Tom</li><li>PJ</li></ul>""") - + 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' ] } - ret = pystache.render(template, context) - self.assertEquals(ret, """<ul><li>Chris</li><li>Tom</li><li>PJ</li></ul>""") + 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 <' -if __name__ == '__main__': - unittest.main() + """ + template = """{{#s1}}foo{{/s1}} {{#s2}}<{{/s2}}""" + context = {'s1': True, 's2': [True]} + self._assert_rendered("foo <", template, context) diff --git a/tests/test_renderengine.py b/tests/test_renderengine.py new file mode 100644 index 0000000..6c2831a --- /dev/null +++ b/tests/test_renderengine.py @@ -0,0 +1,455 @@ +# coding: utf-8 + +""" +Unit tests of renderengine.py. + +""" + +import cgi +import unittest + +from pystache.context import Context +from pystache.parser import ParsingError +from pystache.renderengine import RenderEngine +from tests.common import AssertStringMixin + + +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.assertEquals(engine.escape, "escape") + self.assertEquals(engine.literal, "literal") + self.assertEquals(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 = lambda s: unicode(cgi.escape(s)) + 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 = Context(*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. + + """ + # We use include upper() to make sure we are actually using + # our custom function in the tests + to_unicode = lambda s: unicode(s, encoding='ascii').upper() + engine = self._engine() + engine.escape = to_unicode + engine.literal = to_unicode + + 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) + + def test_interpolation__built_in_type__string(self): + """ + Check tag interpolation with a string on the top of the context stack. + + """ + item = 'abc' + # item.upper() == 'ABC' + template = '{{#section}}{{upper}}{{/section}}' + context = {'section': item, 'upper': 'XYZ'} + self._assert_render(u'XYZ', template, context) + + def test_interpolation__built_in_type__integer(self): + """ + Check tag interpolation with an integer on the top of the context stack. + + """ + item = 10 + # item.real == 10 + template = '{{#section}}{{real}}{{/section}}' + context = {'section': item, 'real': 1000} + self._assert_render(u'1000', template, context) + + def test_interpolation__built_in_type__list(self): + """ + Check tag interpolation with a list on the top of the context stack. + + """ + item = [[1, 2, 3]] + # item[0].pop() == 3 + template = '{{#section}}{{pop}}{{/section}}' + context = {'section': item, 'pop': 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.assertEquals(str(err), "Section end tag mismatch: u'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.assertEquals(str(err), "Section end tag mismatch: u'section_end' != u'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) + + 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_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' '. diff --git a/tests/test_renderer.py b/tests/test_renderer.py new file mode 100644 index 0000000..a69d11a --- /dev/null +++ b/tests/test_renderer.py @@ -0,0 +1,556 @@ +# 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 tests.common import get_data_path +from tests.common import AssertStringMixin +from tests.data.views import SayHello + + +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.assertEquals(renderer.partials, {'foo': 'bar'}) + + def test_escape__default(self): + escape = Renderer().escape + + self.assertEquals(escape(">"), ">") + self.assertEquals(escape('"'), """) + # Single quotes are not escaped. + self.assertEquals(escape("'"), "'") + + def test_escape(self): + escape = lambda s: "**" + s + renderer = Renderer(escape=escape) + self.assertEquals(renderer.escape("bar"), "**bar") + + def test_decode_errors__default(self): + """ + Check the default value. + + """ + renderer = Renderer() + self.assertEquals(renderer.decode_errors, 'strict') + + def test_decode_errors(self): + """ + Check that the constructor sets the attribute correctly. + + """ + renderer = Renderer(decode_errors="foo") + self.assertEquals(renderer.decode_errors, "foo") + + def test_file_encoding__default(self): + """ + Check the file_encoding default. + + """ + renderer = Renderer() + self.assertEquals(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.assertEquals(renderer.file_encoding, 'foo') + + def test_file_extension__default(self): + """ + Check the file_extension default. + + """ + renderer = Renderer() + self.assertEquals(renderer.file_extension, 'mustache') + + def test_file_extension(self): + """ + Check that the file_encoding attribute is set correctly. + + """ + renderer = Renderer(file_extension='foo') + self.assertEquals(renderer.file_extension, 'foo') + + def test_search_dirs__default(self): + """ + Check the search_dirs default. + + """ + renderer = Renderer() + self.assertEquals(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.assertEquals(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.assertEquals(renderer.search_dirs, ['foo']) + + def test_string_encoding__default(self): + """ + Check the default value. + + """ + renderer = Renderer() + self.assertEquals(renderer.string_encoding, sys.getdefaultencoding()) + + def test_string_encoding(self): + """ + Check that the constructor sets the attribute correctly. + + """ + renderer = Renderer(string_encoding="foo") + self.assertEquals(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 = Renderer() + s = "é" + + renderer.string_encoding = "ascii" + self.assertRaises(UnicodeDecodeError, renderer.unicode, s) + + renderer.string_encoding = "utf-8" + self.assertEquals(renderer.unicode(s), u"é") + + def test_unicode__decode_errors(self): + """ + Test that the decode_errors attribute is respected. + + """ + renderer = Renderer() + renderer.string_encoding = "ascii" + s = "déf" + + renderer.decode_errors = "ignore" + self.assertEquals(renderer.unicode(s), "df") + + renderer.decode_errors = "replace" + # U+FFFD is the official Unicode replacement character. + self.assertEquals(renderer.unicode(s), u'd\ufffd\ufffdf') + + ## Test the _make_loader() method. + + def test__make_loader__return_type(self): + """ + Test that _make_loader() returns a Loader. + + """ + renderer = Renderer() + loader = renderer._make_loader() + + self.assertEquals(type(loader), Loader) + + def test__make_loader__attributes(self): + """ + Test that _make_loader() sets all attributes correctly.. + + """ + unicode_ = lambda x: x + + renderer = Renderer() + renderer.file_encoding = 'enc' + renderer.file_extension = 'ext' + renderer.unicode = unicode_ + + loader = renderer._make_loader() + + self.assertEquals(loader.extension, 'ext') + self.assertEquals(loader.file_encoding, 'enc') + self.assertEquals(loader.to_unicode, unicode_) + + ## Test the render() method. + + def test_render__return_type(self): + """ + Check that render() returns a string of type unicode. + + """ + renderer = Renderer() + rendered = renderer.render('foo') + self.assertEquals(type(rendered), unicode) + + def test_render__unicode(self): + renderer = Renderer() + actual = renderer.render(u'foo') + self.assertEquals(actual, u'foo') + + def test_render__str(self): + renderer = Renderer() + actual = renderer.render('foo') + self.assertEquals(actual, 'foo') + + def test_render__non_ascii_character(self): + renderer = Renderer() + actual = renderer.render(u'Poincaré') + self.assertEquals(actual, u'Poincaré') + + def test_render__context(self): + """ + Test render(): passing a context. + + """ + renderer = Renderer() + self.assertEquals(renderer.render('Hi {{person}}', {'person': 'Mom'}), 'Hi Mom') + + def test_render__context_and_kwargs(self): + """ + Test render(): passing a context and **kwargs. + + """ + renderer = Renderer() + template = 'Hi {{person1}} and {{person2}}' + self.assertEquals(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 = Renderer() + self.assertEquals(renderer.render('Hi {{person}}', person='Mom'), 'Hi Mom') + + def test_render__context_and_kwargs__precedence(self): + """ + Test render(): **kwargs takes precedence over context. + + """ + renderer = Renderer() + self.assertEquals(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 = Renderer() + renderer.render('Hi {{person}}', context=context, foo="bar") + self.assertEquals(context, {}) + + def test_render__nonascii_template(self): + """ + Test passing a non-unicode template with non-ascii characters. + + """ + renderer = Renderer() + template = "déf" + + # Check that decode_errors and string_encoding are both respected. + renderer.decode_errors = 'ignore' + renderer.string_encoding = 'ascii' + self.assertEquals(renderer.render(template), "df") + + renderer.string_encoding = 'utf_8' + self.assertEquals(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.assertEquals(actual, 'bar') + self.assertEquals(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.assertEquals(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.assertEquals(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.assertEquals(actual, "Hello, foo") + + def test_render__object(self): + """ + Test rendering an object instance. + + """ + renderer = Renderer() + + say_hello = SayHello() + actual = renderer.render(say_hello) + self.assertEquals('Hello, World', actual) + + actual = renderer.render(say_hello, to='Mars') + self.assertEquals('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.assertEquals('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(). + + """ + + ## 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.assertEquals(actual, "foo") + self.assertEquals(type(actual), unicode) + + # Check that unicode subclasses are not preserved. + actual = engine.load_partial('subclass') + self.assertEquals(actual, "abc") + self.assertEquals(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.assertEquals(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 = Renderer() + renderer.unicode = lambda s: s.upper() + + engine = renderer._make_render_engine() + literal = engine.literal + + self.assertEquals(literal("foo"), "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.assertEquals(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.assertEquals(type(literal("foo")), unicode) + + class MyUnicode(unicode): + pass + + s = MyUnicode("abc") + + self.assertEquals(type(s), MyUnicode) + self.assertTrue(isinstance(s, unicode)) + self.assertEquals(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.assertEquals(escape("foo"), "**foo") + + def test__escape__uses_renderer_unicode(self): + """ + Test that escape uses the renderer's unicode function. + + """ + renderer = Renderer() + renderer.unicode = lambda s: s.upper() + + engine = renderer._make_render_engine() + escape = engine.escape + + self.assertEquals(escape("foo"), "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: type(s).__name__ + + engine = renderer._make_render_engine() + escape = engine.escape + + class MyUnicode(unicode): + pass + + self.assertEquals(escape("foo"), "unicode") + self.assertEquals(escape(u"foo"), "unicode") + self.assertEquals(escape(MyUnicode("foo")), "MyUnicode") + + 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.assertEquals(type(escape("foo")), unicode) + + # Check that literal doesn't preserve unicode subclasses. + class MyUnicode(unicode): + pass + + s = MyUnicode("abc") + + self.assertEquals(type(s), MyUnicode) + self.assertTrue(isinstance(s, unicode)) + self.assertEquals(type(escape(s)), unicode) + diff --git a/tests/test_simple.py b/tests/test_simple.py index 79e7a57..e19187f 100644 --- a/tests/test_simple.py +++ b/tests/test_simple.py @@ -1,49 +1,83 @@ import unittest + import pystache +from pystache import Renderer from examples.nested_context import NestedContext -from examples.complex_view import ComplexView +from examples.complex import Complex from examples.lambdas import Lambdas from examples.template_partial import TemplatePartial from examples.simple import Simple -class TestSimple(unittest.TestCase): - - def test_simple_render(self): - self.assertEqual('herp', pystache.Template('{{derp}}', {'derp': 'herp'}).render()) - +from tests.common import EXAMPLES_DIR +from tests.common import AssertStringMixin + + +class TestSimple(unittest.TestCase, AssertStringMixin): + def test_nested_context(self): - view = NestedContext() - self.assertEquals(pystache.Template('{{#foo}}{{thing1}} and {{thing2}} and {{outer_thing}}{{/foo}}{{^foo}}Not foo!{{/foo}}', view).render(), "one and foo and two") - + 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): - view = ComplexView() - self.assertEquals(pystache.Template('{{#item}}{{header}}: {{name}} {{/item}}{{^item}} Shouldnt see me{{/item}}', view).render(), "Colors: red Colors: green Colors: blue ") + template = '{{#item}}{{header}}: {{name}} {{/item}}{{^item}} Shouldnt see me{{/item}}' + context = Complex() + + renderer = Renderer() + actual = renderer.render(template, context) + self.assertEquals(actual, "Colors: red Colors: green Colors: blue ") def test_empty_context(self): - view = ComplexView() - self.assertEquals(pystache.Template('{{#empty_list}}Shouldnt see me {{/empty_list}}{{^empty_list}}Should see me{{/empty_list}}', view).render(), "Should see me") - + template = '{{#empty_list}}Shouldnt see me {{/empty_list}}{{^empty_list}}Should see me{{/empty_list}}' + self.assertEquals(pystache.Renderer().render(template), "Should see me") + def test_callables(self): view = Lambdas() - self.assertEquals(pystache.Template('{{#replace_foo_with_bar}}foo != bar. oh, it does!{{/replace_foo_with_bar}}', view).render(), 'bar != bar. oh, it does!') - + 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): - view = TemplatePartial() - self.assertEquals(pystache.Template('{{>inner_partial}}', view).render(), 'Again, Welcome!') - - self.assertEquals(pystache.Template('{{#looping}}{{>inner_partial}} {{/looping}}', view).render(), '''Again, Welcome! Again, Welcome! Again, Welcome! ''') - + 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() - - self.assertEquals(pystache.Template('{{not_set}} {{blank}}', view).render(), ' ') - - + template = '{{not_set}} {{blank}}' + self.assertEquals(pystache.Renderer().render(template), ' ') + + def test_template_partial_extension(self): - view = TemplatePartial() - view.template_extension = 'txt' - self.assertEquals(view.render(), """Welcome + """ + 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! -""") +## Again, Welcome! ##""") diff --git a/tests/test_spec.py b/tests/test_spec.py new file mode 100644 index 0000000..02f6080 --- /dev/null +++ b/tests/test_spec.py @@ -0,0 +1,99 @@ +# coding: utf-8 + +""" +Creates a unittest.TestCase for the tests defined in the mustache spec. + +""" + +# TODO: this module can be cleaned up somewhat. + +try: + # We deserialize the json form rather than the yaml form because + # json libraries are available for Python 2.4. + import json +except: + # The json module is new in Python 2.6, whereas simplejson is + # compatible with earlier versions. + import simplejson as json + +import glob +import os.path +import unittest + +from pystache.renderer import Renderer + + +root_path = os.path.join(os.path.dirname(__file__), '..', 'ext', 'spec', 'specs') +spec_paths = glob.glob(os.path.join(root_path, '*.json')) + +class MustacheSpec(unittest.TestCase): + pass + +def buildTest(testData, spec_filename): + + name = testData['name'] + description = testData['desc'] + + test_name = "%s (%s)" % (name, spec_filename) + + def test(self): + template = testData['template'] + partials = testData.has_key('partials') and testData['partials'] or {} + expected = testData['expected'] + data = testData['data'] + + # Convert code strings to functions. + # TODO: make this section of code easier to understand. + new_data = {} + for key, val in data.iteritems(): + if isinstance(val, dict) and val.get('__tag__') == 'code': + val = eval(val['python']) + new_data[key] = val + + renderer = Renderer(partials=partials) + actual = renderer.render(template, new_data) + actual = actual.encode('utf-8') + + message = """%s + + Template: \"""%s\""" + + Expected: %s + Actual: %s + + Expected: \"""%s\""" + Actual: \"""%s\""" + """ % (description, template, repr(expected), repr(actual), expected, actual) + + self.assertEquals(actual, expected, message) + + # The name must begin with "test" for nosetests test discovery to work. + name = 'test: "%s"' % test_name + + # 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) + + return test + +for spec_path in spec_paths: + + file_name = os.path.basename(spec_path) + + # We avoid use of the with keyword for Python 2.4 support. + f = open(spec_path, 'r') + try: + spec_data = json.load(f) + finally: + f.close() + + tests = spec_data['tests'] + + for test in tests: + test = buildTest(test, file_name) + setattr(MustacheSpec, test.__name__, test) + # Prevent this variable from being interpreted as another test. + del(test) + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_template_spec.py b/tests/test_template_spec.py new file mode 100644 index 0000000..9599c37 --- /dev/null +++ b/tests/test_template_spec.py @@ -0,0 +1,388 @@ +# 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.spec_loader import SpecLoader +from tests.common import DATA_DIR +from tests.common import EXAMPLES_DIR +from tests.common import AssertIsMixin +from tests.common import AssertStringMixin +from tests.data.views import SampleView +from 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) + + view.template_rel_directory = "../examples" + actual = renderer.render(view) + self.assertEquals(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.assertEquals(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.assertEquals(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.assertEquals(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.assertEquals(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""") + + +class SpecLoaderTests(unittest.TestCase, AssertIsMixin, AssertStringMixin): + + """ + Tests template_spec.SpecLoader. + + """ + + def test_init__defaults(self): + custom = SpecLoader() + + # Check the loader attribute. + loader = custom.loader + self.assertEquals(loader.extension, 'mustache') + self.assertEquals(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" + + self._assert_template(SpecLoader(), custom, u"abc") + + def test_load__template__type_unicode(self): + """ + Test the template attribute: unicode string. + + """ + custom = TemplateSpec() + custom.template = u"abc" + + self._assert_template(SpecLoader(), custom, u"abc") + + def test_load__template__unicode_non_ascii(self): + """ + Test the template attribute: non-ascii unicode string. + + """ + custom = TemplateSpec() + custom.template = u"é" + + self._assert_template(SpecLoader(), 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') + + self.assertRaises(UnicodeDecodeError, self._assert_template, SpecLoader(), custom, u'é') + + custom.template_encoding = 'utf-8' + self._assert_template(SpecLoader(), 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.assertEquals(loader.s, "template-foo") + self.assertEquals(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): + + # TODO: rename this method to _make_loader(). + def _make_locator(self): + return SpecLoader() + + def _assert_template_location(self, view, expected): + locator = self._make_locator() + actual = locator._find_relative(view) + self.assertEquals(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 test_find__with_directory(self): + """ + Test _find() with a view that has a directory specified. + + """ + locator = self._make_locator() + + view = SampleView() + view.template_rel_path = 'foo/bar.txt' + self.assertTrue(locator._find_relative(view)[0] is not None) + + actual = locator._find(view) + expected = os.path.abspath(os.path.join(DATA_DIR, 'foo/bar.txt')) + + self.assertEquals(actual, expected) + + def test_find__without_directory(self): + """ + Test _find() with a view that doesn't have a directory specified. + + """ + locator = self._make_locator() + + view = SampleView() + self.assertTrue(locator._find_relative(view)[0] is None) + + actual = locator._find(view) + expected = os.path.abspath(os.path.join(DATA_DIR, 'sample_view.mustache')) + + self.assertEquals(actual, expected) + + def _assert_get_template(self, custom, expected): + locator = self._make_locator() + actual = locator.load(custom) + + self.assertEquals(type(actual), unicode) + self.assertEquals(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: é") diff --git a/tests/test_view.py b/tests/test_view.py deleted file mode 100644 index a3464df..0000000 --- a/tests/test_view.py +++ /dev/null @@ -1,112 +0,0 @@ -import unittest -import pystache - -from examples.simple import Simple -from examples.complex_view import ComplexView -from examples.lambdas import Lambdas -from examples.inverted import Inverted, InvertedLists - -class Thing(object): - pass - -class TestView(unittest.TestCase): - def test_basic(self): - view = Simple("Hi {{thing}}!", { 'thing': 'world' }) - self.assertEquals(view.render(), "Hi world!") - - def test_kwargs(self): - view = Simple("Hi {{thing}}!", thing='world') - self.assertEquals(view.render(), "Hi world!") - - def test_template_load(self): - view = Simple(thing='world') - self.assertEquals(view.render(), "Hi world!") - - def test_template_load_from_multiple_path(self): - path = Simple.template_path - Simple.template_path = ('examples/nowhere','examples',) - try: - view = Simple(thing='world') - self.assertEquals(view.render(), "Hi world!") - finally: - Simple.template_path = path - - def test_template_load_from_multiple_path_fail(self): - path = Simple.template_path - Simple.template_path = ('examples/nowhere',) - try: - view = Simple(thing='world') - self.assertRaises(IOError, view.render) - finally: - Simple.template_path = path - - def test_basic_method_calls(self): - view = Simple() - self.assertEquals(view.render(), "Hi pizza!") - - def test_non_callable_attributes(self): - view = Simple() - view.thing = 'Chris' - self.assertEquals(view.render(), "Hi Chris!") - - def test_view_instances_as_attributes(self): - other = Simple(name='chris') - other.template = '{{name}}' - view = Simple() - view.thing = other - self.assertEquals(view.render(), "Hi chris!") - - def test_complex(self): - self.assertEquals(ComplexView().render(), - """<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): - view = Lambdas() - self.assertEquals(view.render(), - 'bar != bar. oh, it does!') - - def test_higher_order_rot13(self): - view = Lambdas() - view.template = '{{#rot13}}abcdefghijklm{{/rot13}}' - self.assertEquals(view.render(), 'nopqrstuvwxyz') - - def test_higher_order_lambda(self): - view = Lambdas() - view.template = '{{#sort}}zyxwvutsrqponmlkjihgfedcba{{/sort}}' - self.assertEquals(view.render(), 'abcdefghijklmnopqrstuvwxyz') - - def test_partials_with_lambda(self): - view = Lambdas() - view.template = '{{>partial_with_lambda}}' - self.assertEquals(view.render(), 'nopqrstuvwxyz') - - def test_hierarchical_partials_with_lambdas(self): - view = Lambdas() - view.template = '{{>partial_with_partial_and_lambda}}' - self.assertEquals(view.render(), 'nopqrstuvwxyznopqrstuvwxyz') - - def test_inverted(self): - view = Inverted() - self.assertEquals(view.render(), """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(context={'parent': parent}) - view.template = "{{#parent}}{{#children}}{{this}}{{/children}}{{/parent}}" - - self.assertEquals(view.render(), 'derp') - - def test_context_returns_a_flattened_dict(self): - view = Simple() - view.context_list = [{'one':'1'}, {'two':'2'}, object()] - - self.assertEqual(view.context, {'one': '1', 'two': '2'}) - - def test_inverted_lists(self): - view = InvertedLists() - self.assertEquals(view.render(), """one, two, three, empty list""") - -if __name__ == '__main__': - unittest.main() |