Commit 8bc8baf3 by Chris Jerdonek

Merge branch 'development' into 'master': staging v0.5.2-rc

parents cc262abf 3c839348
History History
======= =======
0.5.2 (2012-05-03)
------------------
* Added support for dot notation and version 1.1.2 of the spec (issue #99). [rbp]
* Missing partials now render as empty string per latest version of spec (issue #115).
* Bugfix: falsey values now coerced to strings using str().
* Bugfix: lambda return values for sections no longer pushed onto context stack (issue #113).
* Bugfix: lists of lambdas for sections were not rendered (issue #114).
0.5.1 (2012-04-24) 0.5.1 (2012-04-24)
------------------ ------------------
......
...@@ -15,7 +15,7 @@ syntax. For a more complete (and more current) description of Mustache's ...@@ -15,7 +15,7 @@ syntax. For a more complete (and more current) description of Mustache's
behavior, see the official `Mustache spec`_. behavior, see the official `Mustache spec`_.
Pystache is `semantically versioned`_ and can be found on PyPI_. This 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. version of Pystache passes all tests in `version 1.1.2`_ of the spec.
Logo: `David Phillips`_ Logo: `David Phillips`_
...@@ -23,7 +23,7 @@ Logo: `David Phillips`_ ...@@ -23,7 +23,7 @@ Logo: `David Phillips`_
Requirements Requirements
============ ============
Pystache is tested with the following versions of Python: Pystache is tested with--
* Python 2.4 (requires simplejson `version 2.0.9`_ or earlier) * Python 2.4 (requires simplejson `version 2.0.9`_ or earlier)
* Python 2.5 (requires simplejson_) * Python 2.5 (requires simplejson_)
...@@ -49,6 +49,9 @@ Install It ...@@ -49,6 +49,9 @@ Install It
:: ::
pip install pystache pip install pystache
pystache-test
To install and test from source (e.g. from GitHub), see the Develop section.
Use It Use It
...@@ -74,7 +77,8 @@ Like so:: ...@@ -74,7 +77,8 @@ Like so::
>>> from pystache.tests.examples.readme import SayHello >>> from pystache.tests.examples.readme import SayHello
>>> hello = SayHello() >>> hello = SayHello()
Then your template, say_hello.mustache (by default in the same directory):: Then your template, say_hello.mustache (in the same directory by default
as your class definition)::
Hello, {{to}}! Hello, {{to}}!
...@@ -95,9 +99,8 @@ more information. ...@@ -95,9 +99,8 @@ more information.
Python 3 Python 3
======== ========
As of version 0.5.1, Pystache fully supports Python 3. There are slight Pystache has supported Python 3 since version 0.5.1. Pystache behaves
differences in behavior between Pystache running under Python 2 and 3, slightly differently between Python 2 and 3, as follows:
as follows:
* In Python 2, the default html-escape function ``cgi.escape()`` does not * In Python 2, the default html-escape function ``cgi.escape()`` does not
escape single quotes; whereas in Python 3, the default escape function escape single quotes; whereas in Python 3, the default escape function
...@@ -109,14 +112,13 @@ as follows: ...@@ -109,14 +112,13 @@ as follows:
defaults by passing in the encodings explicitly (e.g. to the ``Renderer`` class). defaults by passing in the encodings explicitly (e.g. to the ``Renderer`` class).
Unicode Handling Unicode
================ =======
This section describes Pystache's handling of unicode (e.g. strings and This section describes how Pystache handles unicode, strings, and encodings.
encodings).
Internally, Pystache uses `only unicode strings`_ (type ``str`` in Python 3 and Internally, Pystache uses `only unicode strings`_ (``str`` in Python 3 and
type ``unicode`` in Python 2). For input, Pystache accepts both unicode strings ``unicode`` in Python 2). For input, Pystache accepts both unicode strings
and byte strings (``bytes`` in Python 3 and ``str`` in Python 2). For output, and byte strings (``bytes`` in Python 3 and ``str`` in Python 2). For output,
Pystache's template rendering methods return only unicode. Pystache's template rendering methods return only unicode.
...@@ -143,26 +145,22 @@ attribute can be controlled on a per-view basis by subclassing the ...@@ -143,26 +145,22 @@ attribute can be controlled on a per-view basis by subclassing the
default to values set in Pystache's ``defaults`` module. default to values set in Pystache's ``defaults`` module.
Test It Develop
======= =======
From an install-- :: To test from a source distribution (without installing)-- ::
pystache-test
From a source distribution-- ::
python test_pystache.py python test_pystache.py
To test Pystache source under multiple versions of Python all at once, you To test Pystache with multiple versions of Python (with a single command!),
can use tox_: :: you can use tox_: ::
pip install tox pip install tox
tox tox
If you do not have all Python versions listed in ``tox.ini``-- :: If you do not have all Python versions listed in ``tox.ini``-- ::
tox -e py26,py27 # for example tox -e py26,py32 # for example
The source distribution tests also include doctests and tests from the The source distribution tests also include doctests and tests from the
Mustache spec. To include tests from the Mustache spec in your test runs: :: Mustache spec. To include tests from the Mustache spec in your test runs: ::
...@@ -175,23 +173,32 @@ is present. Otherwise, it parses the json files. To install PyYAML-- :: ...@@ -175,23 +173,32 @@ is present. Otherwise, it parses the json files. To install PyYAML-- ::
pip install pyyaml pip install pyyaml
To test Pystache from a source distribution with Python 3.x, you must use tox. To run a subset of the tests, you can use nose_: ::
This is because the source code must first be run through 2to3_.
pip install nose
nosetests --tests pystache/tests/test_context.py:GetValueTests.test_dictionary__key_present
Mailing List **Running Pystache from source with Python 3.** Pystache is written in
============ Python 2 and must be converted with 2to3_ prior to running under Python 3.
The installation process (and tox) do this conversion automatically.
To ``import pystache`` from a source distribution while using Python 3,
be sure that you are importing from a directory containing a converted
version (e.g. from your site-packages directory after manually installing)
and not from the original source directory. Otherwise, you will get a
syntax error. You can help ensure this by not running the Python IDE
from the project directory when importing Pystache.
As of November 2011, there's a mailing list, pystache@librelist.com.
Archive: http://librelist.com/browser/pystache/ Mailing List
============
Note: There's a bit of a delay in seeing the latest emails appear There is a `mailing list`_. Note that there is a bit of a delay between
in the archive. posting a message and seeing it appear in the mailing list archive.
Author Authors
====== =======
:: ::
...@@ -208,9 +215,11 @@ Author ...@@ -208,9 +215,11 @@ Author
.. _Distribute: http://pypi.python.org/pypi/distribute .. _Distribute: http://pypi.python.org/pypi/distribute
.. _et: http://www.ivan.fomichev.name/2008/05/erlang-template-engine-prototype.html .. _et: http://www.ivan.fomichev.name/2008/05/erlang-template-engine-prototype.html
.. _json: http://docs.python.org/library/json.html .. _json: http://docs.python.org/library/json.html
.. _mailing list: http://librelist.com/browser/pystache/
.. _Mustache: http://mustache.github.com/ .. _Mustache: http://mustache.github.com/
.. _Mustache spec: https://github.com/mustache/spec .. _Mustache spec: https://github.com/mustache/spec
.. _mustache(5): http://mustache.github.com/mustache.5.html .. _mustache(5): http://mustache.github.com/mustache.5.html
.. _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 .. _only unicode strings: http://docs.python.org/howto/unicode.html#tips-for-writing-unicode-aware-programs
.. _PyPI: http://pypi.python.org/pypi/pystache .. _PyPI: http://pypi.python.org/pypi/pystache
.. _Pystache: https://github.com/defunkt/pystache .. _Pystache: https://github.com/defunkt/pystache
...@@ -221,5 +230,5 @@ Author ...@@ -221,5 +230,5 @@ Author
.. _TemplateSpec: https://github.com/defunkt/pystache/blob/master/pystache/template_spec.py .. _TemplateSpec: https://github.com/defunkt/pystache/blob/master/pystache/template_spec.py
.. _test: http://packages.python.org/distribute/setuptools.html#test .. _test: http://packages.python.org/distribute/setuptools.html#test
.. _tox: http://pypi.python.org/pypi/tox .. _tox: http://pypi.python.org/pypi/tox
.. _version 1.0.3: https://github.com/mustache/spec/tree/48c933b0bb780875acbfd15816297e263c53d6f7 .. _version 1.1.2: https://github.com/mustache/spec/tree/v1.1.2
.. _version 2.0.9: http://pypi.python.org/pypi/simplejson/2.0.9 .. _version 2.0.9: http://pypi.python.org/pypi/simplejson/2.0.9
...@@ -4,6 +4,5 @@ TODO ...@@ -4,6 +4,5 @@ TODO
* Turn the benchmarking script at pystache/tests/benchmark.py into a command in pystache/commands, or * Turn the benchmarking script at pystache/tests/benchmark.py into a command in pystache/commands, or
make it a subcommand of one of the existing commands (i.e. using a command argument). make it a subcommand of one of the existing commands (i.e. using a command argument).
* Provide support for logging in at least one of the commands. * Provide support for logging in at least one of the commands.
* Make sure doctest text files can be converted for Python 3 when using tox.
* Make sure command parsing to pystache-test doesn't break with Python 2.4 and earlier. * Make sure command parsing to pystache-test doesn't break with Python 2.4 and earlier.
* Combine pystache-test with the main command. * Combine pystache-test with the main command.
Subproject commit 48c933b0bb780875acbfd15816297e263c53d6f7 Subproject commit 9b1bc7ad19247e9671304af02078f2ce30132665
...@@ -10,4 +10,4 @@ from pystache.init import render, Renderer, TemplateSpec ...@@ -10,4 +10,4 @@ from pystache.init import render, Renderer, TemplateSpec
__all__ = ['render', 'Renderer', 'TemplateSpec'] __all__ = ['render', 'Renderer', 'TemplateSpec']
__version__ = '0.5.1' # Also change in setup.py. __version__ = '0.5.2-rc' # Also change in setup.py.
...@@ -35,6 +35,7 @@ import sys ...@@ -35,6 +35,7 @@ import sys
# #
# ValueError: Attempted relative import in non-package # ValueError: Attempted relative import in non-package
# #
from pystache.common import TemplateNotFoundError
from pystache.renderer import Renderer from pystache.renderer import Renderer
...@@ -78,7 +79,7 @@ def main(sys_argv=sys.argv): ...@@ -78,7 +79,7 @@ def main(sys_argv=sys.argv):
try: try:
template = renderer.load_template(template) template = renderer.load_template(template)
except IOError: except TemplateNotFoundError:
pass pass
try: try:
......
...@@ -7,7 +7,7 @@ This module provides a command to test pystache (unit tests, doctests, etc). ...@@ -7,7 +7,7 @@ This module provides a command to test pystache (unit tests, doctests, etc).
import sys import sys
from pystache.tests.main import run_tests from pystache.tests.main import main as run_tests
def main(sys_argv=sys.argv): def main(sys_argv=sys.argv):
......
# coding: utf-8 # coding: utf-8
""" """
Exposes common functions. Exposes functionality needed throughout the project.
""" """
...@@ -24,3 +24,13 @@ def read(path): ...@@ -24,3 +24,13 @@ def read(path):
return f.read() return f.read()
finally: finally:
f.close() f.close()
class PystacheError(Exception):
"""Base class for Pystache exceptions."""
pass
class TemplateNotFoundError(PystacheError):
"""An exception raised when a template is not found."""
pass
# coding: utf-8 # coding: utf-8
""" """
Defines a Context class to represent mustache(5)'s notion of context. Exposes a ContextStack class.
The Mustache spec makes a special distinction between two types of context
stack elements: hashes and objects. For the purposes of interpreting the
spec, we define these categories mutually exclusively as follows:
(1) Hash: an item whose type is a subclass of dict.
(2) Object: an item that is neither a hash nor an instance of a
built-in type.
""" """
...@@ -22,28 +31,23 @@ class NotFound(object): ...@@ -22,28 +31,23 @@ class NotFound(object):
_NOT_FOUND = NotFound() _NOT_FOUND = NotFound()
# TODO: share code with template.check_callable(). def _get_value(context, key):
def _is_callable(obj):
return hasattr(obj, '__call__')
def _get_value(item, key):
""" """
Retrieve a key's value from an item. Retrieve a key's value from a context item.
Returns _NOT_FOUND if the key does not exist. Returns _NOT_FOUND if the key does not exist.
The Context.get() docstring documents this function's intended behavior. The ContextStack.get() docstring documents this function's intended behavior.
""" """
if isinstance(item, dict): if isinstance(context, dict):
# Then we consider the argument a "hash" for the purposes of the spec. # 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 # We do a membership test to avoid using exceptions for flow control
# (e.g. catching KeyError). # (e.g. catching KeyError).
if key in item: if key in context:
return item[key] return context[key]
elif type(item).__module__ != _BUILTIN_MODULE: elif type(context).__module__ != _BUILTIN_MODULE:
# Then we consider the argument an "object" for the purposes of # Then we consider the argument an "object" for the purposes of
# the spec. # the spec.
# #
...@@ -51,16 +55,18 @@ def _get_value(item, key): ...@@ -51,16 +55,18 @@ def _get_value(item, key):
# types like integers and strings as objects (cf. issue #81). # types like integers and strings as objects (cf. issue #81).
# Instances of user-defined classes on the other hand, for example, # Instances of user-defined classes on the other hand, for example,
# are considered objects by the test above. # are considered objects by the test above.
if hasattr(item, key): if hasattr(context, key):
attr = getattr(item, key) attr = getattr(context, key)
if _is_callable(attr): # TODO: consider using EAFP here instead.
# http://docs.python.org/glossary.html#term-eafp
if callable(attr):
return attr() return attr()
return attr return attr
return _NOT_FOUND return _NOT_FOUND
class Context(object): class ContextStack(object):
""" """
Provides dictionary-like access to a stack of zero or more items. Provides dictionary-like access to a stack of zero or more items.
...@@ -75,7 +81,7 @@ class Context(object): ...@@ -75,7 +81,7 @@ class Context(object):
(last in, first out). (last in, first out).
Caution: this class does not currently support recursive nesting in Caution: this class does not currently support recursive nesting in
that items in the stack cannot themselves be Context instances. that items in the stack cannot themselves be ContextStack instances.
See the docstrings of the methods of this class for more details. See the docstrings of the methods of this class for more details.
...@@ -92,7 +98,7 @@ class Context(object): ...@@ -92,7 +98,7 @@ class Context(object):
stack in order so that, in particular, items at the end of stack in order so that, in particular, items at the end of
the argument list are queried first when querying the stack. the argument list are queried first when querying the stack.
Caution: items should not themselves be Context instances, as Caution: items should not themselves be ContextStack instances, as
recursive nesting does not behave as one might expect. recursive nesting does not behave as one might expect.
""" """
...@@ -104,9 +110,9 @@ class Context(object): ...@@ -104,9 +110,9 @@ class Context(object):
For example-- For example--
>>> context = Context({'alpha': 'abc'}, {'numeric': 123}) >>> context = ContextStack({'alpha': 'abc'}, {'numeric': 123})
>>> repr(context) >>> repr(context)
"Context({'alpha': 'abc'}, {'numeric': 123})" "ContextStack({'alpha': 'abc'}, {'numeric': 123})"
""" """
return "%s%s" % (self.__class__.__name__, tuple(self._stack)) return "%s%s" % (self.__class__.__name__, tuple(self._stack))
...@@ -114,18 +120,18 @@ class Context(object): ...@@ -114,18 +120,18 @@ class Context(object):
@staticmethod @staticmethod
def create(*context, **kwargs): def create(*context, **kwargs):
""" """
Build a Context instance from a sequence of context-like items. Build a ContextStack instance from a sequence of context-like items.
This factory-style method is more general than the Context class's This factory-style method is more general than the ContextStack class's
constructor in that, unlike the constructor, the argument list constructor in that, unlike the constructor, the argument list
can itself contain Context instances. can itself contain ContextStack instances.
Here is an example illustrating various aspects of this method: Here is an example illustrating various aspects of this method:
>>> obj1 = {'animal': 'cat', 'vegetable': 'carrot', 'mineral': 'copper'} >>> obj1 = {'animal': 'cat', 'vegetable': 'carrot', 'mineral': 'copper'}
>>> obj2 = Context({'vegetable': 'spinach', 'mineral': 'silver'}) >>> obj2 = ContextStack({'vegetable': 'spinach', 'mineral': 'silver'})
>>> >>>
>>> context = Context.create(obj1, None, obj2, mineral='gold') >>> context = ContextStack.create(obj1, None, obj2, mineral='gold')
>>> >>>
>>> context.get('animal') >>> context.get('animal')
'cat' 'cat'
...@@ -136,7 +142,7 @@ class Context(object): ...@@ -136,7 +142,7 @@ class Context(object):
Arguments: Arguments:
*context: zero or more dictionaries, Context instances, or objects *context: zero or more dictionaries, ContextStack instances, or objects
with which to populate the initial context stack. None with which to populate the initial context stack. None
arguments will be skipped. Items in the *context list are arguments will be skipped. Items in the *context list are
added to the stack in order so that later items in the argument added to the stack in order so that later items in the argument
...@@ -152,12 +158,12 @@ class Context(object): ...@@ -152,12 +158,12 @@ class Context(object):
""" """
items = context items = context
context = Context() context = ContextStack()
for item in items: for item in items:
if item is None: if item is None:
continue continue
if isinstance(item, Context): if isinstance(item, ContextStack):
context._stack.extend(item._stack) context._stack.extend(item._stack)
else: else:
context.push(item) context.push(item)
...@@ -167,9 +173,22 @@ class Context(object): ...@@ -167,9 +173,22 @@ class Context(object):
return context return context
def get(self, key, default=None): # TODO: add more unit tests for this.
# TODO: update the docstring for dotted names.
def get(self, name, default=u''):
""" """
Query the stack for the given key, and return the resulting value. Resolve a dotted name against the current context stack.
This function follows the rules outlined in the section of the
spec regarding tag interpolation. This function returns the value
as is and does not coerce the return value to a string.
Arguments:
name: a dotted or non-dotted name.
default: the value to return if name resolution fails at any point.
Defaults to the empty string per the Mustache spec.
This method queries items in the stack in order from last-added This method queries items in the stack in order from last-added
objects to first (last in, first out). The value returned is objects to first (last in, first out). The value returned is
...@@ -177,30 +196,21 @@ class Context(object): ...@@ -177,30 +196,21 @@ class Context(object):
If the key is not found in any item in the stack, then the default If the key is not found in any item in the stack, then the default
value is returned. The default value defaults to None. 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 In accordance with the spec, this method queries items in the
stack for a key in the following way. For the purposes of querying, stack for a key differently depending on whether the item is a
each item is classified into one of the following three mutually hash, object, or neither (as defined in the module docstring):
exclusive categories: a hash, an object, or neither:
(1) Hash: if the item is a hash, then the key's value is the
(1) Hash: if the item's type is a subclass of dict, then the item dictionary value of the key. If the dictionary doesn't contain
is considered a hash (in the terminology of the spec), and the key, then the key is considered not found.
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 is an an object, then the method looks for
an attribute with the same name as the key. If an attribute
(2) Object: if the item isn't a hash and isn't an instance of a with that name exists, the value of the attribute is returned.
built-in type, then the item is considered an object (again If the attribute is callable, however (i.e. if the attribute
using the language of the spec). In this case, the method is a method), then the attribute is called with no arguments
looks for an attribute with the same name as the key. If an and that value is returned. If there is no attribute with
attribute with that name exists, the value of the attribute is the same name as the key, then the key is considered not found.
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 (3) Neither: if the item is neither a hash nor an object, then
the key is considered not found. the key is considered not found.
...@@ -226,23 +236,59 @@ class Context(object): ...@@ -226,23 +236,59 @@ class Context(object):
>>> >>>
>>> dct['greet'] is obj.greet >>> dct['greet'] is obj.greet
True True
>>> Context(dct).get('greet') #doctest: +ELLIPSIS >>> ContextStack(dct).get('greet') #doctest: +ELLIPSIS
<function greet at 0x...> <function greet at 0x...>
>>> Context(obj).get('greet') >>> ContextStack(obj).get('greet')
'Hi Bob!' 'Hi Bob!'
TODO: explain the rationale for this difference in treatment. TODO: explain the rationale for this difference in treatment.
""" """
for obj in reversed(self._stack): if name == '.':
val = _get_value(obj, key) # TODO: should we add a test case for an empty context stack?
if val is _NOT_FOUND: return self.top()
parts = name.split('.')
result = self._get_simple(parts[0])
for part in parts[1:]:
# TODO: consider using EAFP here instead.
# http://docs.python.org/glossary.html#term-eafp
if result is _NOT_FOUND:
break
# The full context stack is not used to resolve the remaining parts.
# From the spec--
#
# 5) If any name parts were retained in step 1, each should be
# resolved against a context stack containing only the result
# from the former resolution. If any part fails resolution, the
# result should be considered falsey, and should interpolate as
# the empty string.
#
# TODO: make sure we have a test case for the above point.
result = _get_value(result, part)
if result is _NOT_FOUND:
return default
return result
def _get_simple(self, name):
"""
Query the stack for a non-dotted name.
"""
result = _NOT_FOUND
for item in reversed(self._stack):
result = _get_value(item, name)
if result is _NOT_FOUND:
continue continue
# Otherwise, the key was found. # Otherwise, the key was found.
return val break
# Otherwise, no item in the stack contained the key.
return default return result
def push(self, item): def push(self, item):
""" """
...@@ -270,4 +316,4 @@ class Context(object): ...@@ -270,4 +316,4 @@ class Context(object):
Return a copy of this instance. Return a copy of this instance.
""" """
return Context(*self._stack) return ContextStack(*self._stack)
...@@ -9,6 +9,7 @@ import os ...@@ -9,6 +9,7 @@ import os
import re import re
import sys import sys
from pystache.common import TemplateNotFoundError
from pystache import defaults from pystache import defaults
...@@ -117,9 +118,8 @@ class Locator(object): ...@@ -117,9 +118,8 @@ class Locator(object):
path = self._find_path(search_dirs, file_name) path = self._find_path(search_dirs, file_name)
if path is None: if path is None:
# TODO: we should probably raise an exception of our own type. raise TemplateNotFoundError('File %s not found in dirs: %s' %
raise IOError('Template file %s not found in directories: %s' % (repr(file_name), repr(search_dirs)))
(repr(file_name), repr(search_dirs)))
return path return path
......
...@@ -17,7 +17,7 @@ class ParsedTemplate(object): ...@@ -17,7 +17,7 @@ class ParsedTemplate(object):
parse_tree: a list, each element of which is either-- parse_tree: a list, each element of which is either--
(1) a unicode string, or (1) a unicode string, or
(2) a "rendering" callable that accepts a Context instance (2) a "rendering" callable that accepts a ContextStack instance
and returns a unicode string. and returns a unicode string.
The possible rendering callables are the return values of the The possible rendering callables are the return values of the
...@@ -32,6 +32,9 @@ class ParsedTemplate(object): ...@@ -32,6 +32,9 @@ class ParsedTemplate(object):
""" """
self._parse_tree = parse_tree self._parse_tree = parse_tree
def __repr__(self):
return "[%s]" % (", ".join([repr(part) for part in self._parse_tree]))
def render(self, context): def render(self, context):
""" """
Returns: a string of type unicode. Returns: a string of type unicode.
......
...@@ -9,15 +9,22 @@ This module is only meant for internal use by the renderengine module. ...@@ -9,15 +9,22 @@ This module is only meant for internal use by the renderengine module.
import re import re
from pystache.common import TemplateNotFoundError
from pystache.parsed import ParsedTemplate from pystache.parsed import ParsedTemplate
DEFAULT_DELIMITERS = ('{{', '}}') DEFAULT_DELIMITERS = (u'{{', u'}}')
END_OF_LINE_CHARACTERS = ['\r', '\n'] END_OF_LINE_CHARACTERS = [u'\r', u'\n']
NON_BLANK_RE = re.compile(r'^(.)', re.M) NON_BLANK_RE = re.compile(ur'^(.)', re.M)
def _compile_template_re(delimiters): def _compile_template_re(delimiters=None):
"""
Return a regular expresssion object (re.RegexObject) instance.
"""
if delimiters is None:
delimiters = DEFAULT_DELIMITERS
# The possible tag type characters following the opening tag, # The possible tag type characters following the opening tag,
# excluding "=" and "{". # excluding "=" and "{".
...@@ -74,19 +81,25 @@ class Parser(object): ...@@ -74,19 +81,25 @@ class Parser(object):
self._delimiters = delimiters self._delimiters = delimiters
self.compile_template_re() self.compile_template_re()
def parse(self, template, index=0, section_key=None): def parse(self, template, start_index=0, section_key=None):
""" """
Parse a template string into a ParsedTemplate instance. Parse a template string starting at some index.
This method uses the current tag delimiter. This method uses the current tag delimiter.
Arguments: Arguments:
template: a template string of type unicode. template: a unicode string that is the template to parse.
index: the index at which to start parsing.
Returns:
a ParsedTemplate instance.
""" """
parse_tree = [] parse_tree = []
start_index = index index = start_index
while True: while True:
match = self._template_re.search(template, index) match = self._template_re.search(template, index)
...@@ -133,7 +146,7 @@ class Parser(object): ...@@ -133,7 +146,7 @@ class Parser(object):
if tag_key != section_key: if tag_key != section_key:
raise ParsingError("Section end tag mismatch: %s != %s" % (tag_key, section_key)) raise ParsingError("Section end tag mismatch: %s != %s" % (tag_key, section_key))
return ParsedTemplate(parse_tree), template[start_index:match_index], end_index return ParsedTemplate(parse_tree), match_index, end_index
index = self._handle_tag_type(template, parse_tree, tag_type, tag_key, leading_whitespace, end_index) index = self._handle_tag_type(template, parse_tree, tag_type, tag_key, leading_whitespace, end_index)
...@@ -142,10 +155,33 @@ class Parser(object): ...@@ -142,10 +155,33 @@ class Parser(object):
return ParsedTemplate(parse_tree) return ParsedTemplate(parse_tree)
def _parse_section(self, template, index_start, section_key): def _parse_section(self, template, start_index, section_key):
parsed_template, template, index_end = self.parse(template=template, index=index_start, section_key=section_key) """
Parse the contents of a template section.
Arguments:
template: a unicode template string.
start_index: the string index at which the section contents begin.
section_key: the tag key of the section.
Returns: a 3-tuple:
parsed_section: the section contents parsed as a ParsedTemplate
instance.
content_end_index: the string index after the section contents.
end_index: the string index after the closing section tag (and
including any trailing newlines).
"""
parsed_section, content_end_index, end_index = \
self.parse(template=template, start_index=start_index, section_key=section_key)
return parsed_template, template, index_end return parsed_section, template[start_index:content_end_index], end_index
def _handle_tag_type(self, template, parse_tree, tag_type, tag_key, leading_whitespace, end_index): def _handle_tag_type(self, template, parse_tree, tag_type, tag_key, leading_whitespace, end_index):
...@@ -170,20 +206,24 @@ class Parser(object): ...@@ -170,20 +206,24 @@ class Parser(object):
elif tag_type == '#': elif tag_type == '#':
parsed_section, template, end_index = self._parse_section(template, end_index, tag_key) parsed_section, section_contents, end_index = self._parse_section(template, end_index, tag_key)
func = engine._make_get_section(tag_key, parsed_section, template, self._delimiters) func = engine._make_get_section(tag_key, parsed_section, section_contents, self._delimiters)
elif tag_type == '^': elif tag_type == '^':
parsed_section, template, end_index = self._parse_section(template, end_index, tag_key) parsed_section, section_contents, end_index = self._parse_section(template, end_index, tag_key)
func = engine._make_get_inverse(tag_key, parsed_section) func = engine._make_get_inverse(tag_key, parsed_section)
elif tag_type == '>': elif tag_type == '>':
template = engine.load_partial(tag_key) try:
# TODO: make engine.load() and test it separately.
template = engine.load_partial(tag_key)
except TemplateNotFoundError:
template = u''
# Indent before rendering. # Indent before rendering.
template = re.sub(NON_BLANK_RE, leading_whitespace + r'\1', template) template = re.sub(NON_BLANK_RE, leading_whitespace + ur'\1', template)
func = engine._make_get_partial(template) func = engine._make_get_partial(template)
......
...@@ -35,7 +35,8 @@ class RenderEngine(object): ...@@ -35,7 +35,8 @@ class RenderEngine(object):
load_partial: the function to call when loading a partial. The load_partial: the function to call when loading a partial. The
function should accept a string template name and return a function should accept a string template name and return a
template string of type unicode (not a subclass). template string of type unicode (not a subclass). If the
template is not found, it should raise a TemplateNotFoundError.
literal: the function used to convert unescaped variable tag literal: the function used to convert unescaped variable tag
values to unicode, e.g. the value corresponding to a tag values to unicode, e.g. the value corresponding to a tag
...@@ -63,6 +64,7 @@ class RenderEngine(object): ...@@ -63,6 +64,7 @@ class RenderEngine(object):
self.literal = literal self.literal = literal
self.load_partial = load_partial self.load_partial = load_partial
# TODO: rename context to stack throughout this module.
def _get_string_value(self, context, tag_name): def _get_string_value(self, context, tag_name):
""" """
Get a value from the given context as a basestring instance. Get a value from the given context as a basestring instance.
...@@ -70,15 +72,6 @@ class RenderEngine(object): ...@@ -70,15 +72,6 @@ class RenderEngine(object):
""" """
val = context.get(tag_name) 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): if callable(val):
# According to the spec: # According to the spec:
# #
...@@ -132,6 +125,7 @@ class RenderEngine(object): ...@@ -132,6 +125,7 @@ class RenderEngine(object):
Returns: a string of type unicode. Returns: a string of type unicode.
""" """
# TODO: the parsing should be done before calling this function.
return self._render(template, context) return self._render(template, context)
return get_partial return get_partial
...@@ -142,7 +136,10 @@ class RenderEngine(object): ...@@ -142,7 +136,10 @@ class RenderEngine(object):
Returns a string with type unicode. Returns a string with type unicode.
""" """
# TODO: is there a bug because we are not using the same
# logic as in _get_string_value()?
data = context.get(name) data = context.get(name)
# Per the spec, lambdas in inverted sections are considered truthy.
if data: if data:
return u'' return u''
return parsed_template.render(context) return parsed_template.render(context)
...@@ -161,16 +158,19 @@ class RenderEngine(object): ...@@ -161,16 +158,19 @@ class RenderEngine(object):
template = template_ template = template_
parsed_template = parsed_template_ parsed_template = parsed_template_
data = context.get(name) data = context.get(name)
# From the spec:
#
# If the data is not of a list type, it is coerced into a list
# as follows: if the data is truthy (e.g. `!!data == true`),
# use a single-element list containing the data, otherwise use
# an empty list.
#
if not data: if not data:
data = [] data = []
elif callable(data):
# TODO: should we check the arity?
template = data(template)
parsed_template = self._parse(template, delimiters=delims)
data = [data]
else: else:
# The cleanest, least brittle way of determining whether # The least brittle way to determine whether something
# something supports iteration is by trying to call iter() on it: # supports iteration is by trying to call iter() on it:
# #
# http://docs.python.org/library/functions.html#iter # http://docs.python.org/library/functions.html#iter
# #
...@@ -184,14 +184,34 @@ class RenderEngine(object): ...@@ -184,14 +184,34 @@ class RenderEngine(object):
# Then the value does not support iteration. # Then the value does not support iteration.
data = [data] data = [data]
else: else:
# We treat the value as a list (but do not treat strings
# and dicts as lists).
if isinstance(data, (basestring, dict)): if isinstance(data, (basestring, dict)):
# Do not treat strings and dicts (which are iterable) as lists.
data = [data] data = [data]
# Otherwise, leave it alone. # Otherwise, treat the value as a list.
parts = [] parts = []
for element in data: for element in data:
if callable(element):
# Lambdas special case section rendering and bypass pushing
# the data value onto the context stack. From the spec--
#
# When used as the data value for a Section tag, the
# lambda MUST be treatable as an arity 1 function, and
# invoked as such (passing a String containing the
# unprocessed section contents). The returned value
# MUST be rendered against the current delimiters, then
# interpolated in place of the section.
#
# Also see--
#
# https://github.com/defunkt/pystache/issues/113
#
# TODO: should we check the arity?
new_template = element(template)
parsed_template = self._parse(new_template, delimiters=delims)
parts.append(parsed_template.render(context))
continue
context.push(element) context.push(element)
parts.append(parsed_template.render(context)) parts.append(parsed_template.render(context))
context.pop() context.pop()
...@@ -221,7 +241,7 @@ class RenderEngine(object): ...@@ -221,7 +241,7 @@ class RenderEngine(object):
Arguments: Arguments:
template: a template string of type unicode. template: a template string of type unicode.
context: a Context instance. context: a ContextStack instance.
""" """
# We keep this type-check as an added check because this method is # We keep this type-check as an added check because this method is
...@@ -244,7 +264,7 @@ class RenderEngine(object): ...@@ -244,7 +264,7 @@ class RenderEngine(object):
template: a template string of type unicode (but not a proper template: a template string of type unicode (but not a proper
subclass of unicode). subclass of unicode).
context: a Context instance. context: a ContextStack instance.
""" """
# Be strict but not too strict. In other words, accept str instead # Be strict but not too strict. In other words, accept str instead
......
...@@ -8,7 +8,8 @@ This module provides a Renderer class to render templates. ...@@ -8,7 +8,8 @@ This module provides a Renderer class to render templates.
import sys import sys
from pystache import defaults from pystache import defaults
from pystache.context import Context from pystache.common import TemplateNotFoundError
from pystache.context import ContextStack
from pystache.loader import Loader from pystache.loader import Loader
from pystache.renderengine import RenderEngine from pystache.renderengine import RenderEngine
from pystache.specloader import SpecLoader from pystache.specloader import SpecLoader
...@@ -239,9 +240,8 @@ class Renderer(object): ...@@ -239,9 +240,8 @@ class Renderer(object):
template = partials.get(name) template = partials.get(name)
if template is None: if template is None:
# TODO: make a TemplateNotFoundException type that provides raise TemplateNotFoundError("Name %s not found in partials: %s" %
# the original partials as an attribute. (repr(name), type(partials)))
raise Exception("Partial not found with name: %s" % repr(name))
# RenderEngine requires that the return value be unicode. # RenderEngine requires that the return value be unicode.
return self._to_unicode_hard(template) return self._to_unicode_hard(template)
...@@ -277,7 +277,7 @@ class Renderer(object): ...@@ -277,7 +277,7 @@ class Renderer(object):
# RenderEngine.render() requires that the template string be unicode. # RenderEngine.render() requires that the template string be unicode.
template = self._to_unicode_hard(template) template = self._to_unicode_hard(template)
context = Context.create(*context, **kwargs) context = ContextStack.create(*context, **kwargs)
self._context = context self._context = context
engine = self._make_render_engine() engine = self._make_render_engine()
...@@ -338,7 +338,7 @@ class Renderer(object): ...@@ -338,7 +338,7 @@ class Renderer(object):
uses the passed object as the first element of the context stack uses the passed object as the first element of the context stack
when rendering. when rendering.
*context: zero or more dictionaries, Context instances, or objects *context: zero or more dictionaries, ContextStack instances, or objects
with which to populate the initial context stack. None with which to populate the initial context stack. None
arguments are skipped. Items in the *context list are added to arguments are skipped. Items in the *context list are added to
the context stack in order so that later items in the argument the context stack in order so that later items in the argument
......
...@@ -168,6 +168,20 @@ class AssertIsMixin: ...@@ -168,6 +168,20 @@ class AssertIsMixin:
self.assertTrue(first is second, msg="%s is not %s" % (repr(first), repr(second))) self.assertTrue(first is second, msg="%s is not %s" % (repr(first), repr(second)))
class AssertExceptionMixin:
"""A unittest.TestCase mixin adding assertException()."""
# unittest.assertRaisesRegexp() is not available until Python 2.7:
# http://docs.python.org/library/unittest.html#unittest.TestCase.assertRaisesRegexp
def assertException(self, exception_type, msg, callable, *args, **kwds):
try:
callable(*args, **kwds)
raise Exception("Expected exception: %s: %s" % (exception_type, repr(msg)))
except exception_type, err:
self.assertEqual(str(err), msg)
class SetupDefaults(object): class SetupDefaults(object):
""" """
...@@ -191,3 +205,28 @@ class SetupDefaults(object): ...@@ -191,3 +205,28 @@ class SetupDefaults(object):
defaults.FILE_ENCODING = self.original_file_encoding defaults.FILE_ENCODING = self.original_file_encoding
defaults.STRING_ENCODING = self.original_string_encoding defaults.STRING_ENCODING = self.original_string_encoding
class Attachable(object):
"""
A class that attaches all constructor named parameters as attributes.
For example--
>>> obj = Attachable(foo=42, size="of the universe")
>>> repr(obj)
"Attachable(foo=42, size='of the universe')"
>>> obj.foo
42
>>> obj.size
'of the universe'
"""
def __init__(self, **kwargs):
self.__args__ = kwargs
for arg, value in kwargs.iteritems():
setattr(self, arg, value)
def __repr__(self):
return "%s(%s)" % (self.__class__.__name__,
", ".join("%s=%s" % (k, repr(v))
for k, v in self.__args__.iteritems()))
...@@ -12,4 +12,4 @@ class Simple(TemplateSpec): ...@@ -12,4 +12,4 @@ class Simple(TemplateSpec):
return "pizza" return "pizza"
def blank(self): def blank(self):
pass return ''
...@@ -24,7 +24,9 @@ from pystache.tests.spectesting import get_spec_tests ...@@ -24,7 +24,9 @@ from pystache.tests.spectesting import get_spec_tests
FROM_SOURCE_OPTION = "--from-source" FROM_SOURCE_OPTION = "--from-source"
def run_tests(sys_argv): # Do not include "test" in this function's name to avoid it getting
# picked up by nosetests.
def main(sys_argv):
""" """
Run all tests in the project. Run all tests in the project.
......
...@@ -115,6 +115,37 @@ def _read_spec_tests(path): ...@@ -115,6 +115,37 @@ def _read_spec_tests(path):
return cases return cases
# TODO: simplify the implementation of this function.
def _convert_children(node):
"""
Recursively convert to functions all "code strings" below the node.
This function is needed only for the json format.
"""
if not isinstance(node, (list, dict)):
# Then there is nothing to iterate over and recurse.
return
if isinstance(node, list):
for child in node:
_convert_children(child)
return
# Otherwise, node is a dict, so attempt the conversion.
for key in node.keys():
val = node[key]
if not isinstance(val, dict) or val.get('__tag__') != 'code':
_convert_children(val)
continue
# Otherwise, we are at a "leaf" node.
val = eval(val['python'])
node[key] = val
continue
def _deserialize_spec_test(data, file_path): def _deserialize_spec_test(data, file_path):
""" """
Return a unittest.TestCase instance representing a spec test. Return a unittest.TestCase instance representing a spec test.
...@@ -124,7 +155,7 @@ def _deserialize_spec_test(data, file_path): ...@@ -124,7 +155,7 @@ def _deserialize_spec_test(data, file_path):
data: the dictionary of attributes for a single test. data: the dictionary of attributes for a single test.
""" """
unconverted_context = data['data'] context = data['data']
description = data['desc'] description = data['desc']
# PyYAML seems to leave ASCII strings as byte strings. # PyYAML seems to leave ASCII strings as byte strings.
expected = unicode(data['expected']) expected = unicode(data['expected'])
...@@ -133,13 +164,7 @@ def _deserialize_spec_test(data, file_path): ...@@ -133,13 +164,7 @@ def _deserialize_spec_test(data, file_path):
template = data['template'] template = data['template']
test_name = data['name'] test_name = data['name']
# Convert code strings to functions. _convert_children(context)
# TODO: make this section of code easier to understand.
context = {}
for key, val in unconverted_context.iteritems():
if isinstance(val, dict) and val.get('__tag__') == 'code':
val = eval(val['python'])
context[key] = val
test_case = _make_spec_test(expected, template, context, partials, description, test_name, file_path) test_case = _make_spec_test(expected, template, context, partials, description, test_name, file_path)
......
...@@ -10,8 +10,8 @@ import unittest ...@@ -10,8 +10,8 @@ import unittest
from pystache.context import _NOT_FOUND from pystache.context import _NOT_FOUND
from pystache.context import _get_value from pystache.context import _get_value
from pystache.context import Context from pystache.context import ContextStack
from pystache.tests.common import AssertIsMixin from pystache.tests.common import AssertIsMixin, AssertStringMixin, Attachable
class SimpleObject(object): class SimpleObject(object):
...@@ -204,10 +204,10 @@ class GetValueTests(unittest.TestCase, AssertIsMixin): ...@@ -204,10 +204,10 @@ class GetValueTests(unittest.TestCase, AssertIsMixin):
self.assertNotFound(item2, 'pop') self.assertNotFound(item2, 'pop')
class ContextTests(unittest.TestCase, AssertIsMixin): class ContextStackTests(unittest.TestCase, AssertIsMixin, AssertStringMixin):
""" """
Test the Context class. Test the ContextStack class.
""" """
...@@ -216,34 +216,34 @@ class ContextTests(unittest.TestCase, AssertIsMixin): ...@@ -216,34 +216,34 @@ class ContextTests(unittest.TestCase, AssertIsMixin):
Check that passing nothing to __init__() raises no exception. Check that passing nothing to __init__() raises no exception.
""" """
context = Context() context = ContextStack()
def test_init__many_elements(self): def test_init__many_elements(self):
""" """
Check that passing more than two items to __init__() raises no exception. Check that passing more than two items to __init__() raises no exception.
""" """
context = Context({}, {}, {}) context = ContextStack({}, {}, {})
def test__repr(self): def test__repr(self):
context = Context() context = ContextStack()
self.assertEqual(repr(context), 'Context()') self.assertEqual(repr(context), 'ContextStack()')
context = Context({'foo': 'bar'}) context = ContextStack({'foo': 'bar'})
self.assertEqual(repr(context), "Context({'foo': 'bar'},)") self.assertEqual(repr(context), "ContextStack({'foo': 'bar'},)")
context = Context({'foo': 'bar'}, {'abc': 123}) context = ContextStack({'foo': 'bar'}, {'abc': 123})
self.assertEqual(repr(context), "Context({'foo': 'bar'}, {'abc': 123})") self.assertEqual(repr(context), "ContextStack({'foo': 'bar'}, {'abc': 123})")
def test__str(self): def test__str(self):
context = Context() context = ContextStack()
self.assertEqual(str(context), 'Context()') self.assertEqual(str(context), 'ContextStack()')
context = Context({'foo': 'bar'}) context = ContextStack({'foo': 'bar'})
self.assertEqual(str(context), "Context({'foo': 'bar'},)") self.assertEqual(str(context), "ContextStack({'foo': 'bar'},)")
context = Context({'foo': 'bar'}, {'abc': 123}) context = ContextStack({'foo': 'bar'}, {'abc': 123})
self.assertEqual(str(context), "Context({'foo': 'bar'}, {'abc': 123})") self.assertEqual(str(context), "ContextStack({'foo': 'bar'}, {'abc': 123})")
## Test the static create() method. ## Test the static create() method.
...@@ -252,7 +252,7 @@ class ContextTests(unittest.TestCase, AssertIsMixin): ...@@ -252,7 +252,7 @@ class ContextTests(unittest.TestCase, AssertIsMixin):
Test passing a dictionary. Test passing a dictionary.
""" """
context = Context.create({'foo': 'bar'}) context = ContextStack.create({'foo': 'bar'})
self.assertEqual(context.get('foo'), 'bar') self.assertEqual(context.get('foo'), 'bar')
def test_create__none(self): def test_create__none(self):
...@@ -260,7 +260,7 @@ class ContextTests(unittest.TestCase, AssertIsMixin): ...@@ -260,7 +260,7 @@ class ContextTests(unittest.TestCase, AssertIsMixin):
Test passing None. Test passing None.
""" """
context = Context.create({'foo': 'bar'}, None) context = ContextStack.create({'foo': 'bar'}, None)
self.assertEqual(context.get('foo'), 'bar') self.assertEqual(context.get('foo'), 'bar')
def test_create__object(self): def test_create__object(self):
...@@ -270,16 +270,16 @@ class ContextTests(unittest.TestCase, AssertIsMixin): ...@@ -270,16 +270,16 @@ class ContextTests(unittest.TestCase, AssertIsMixin):
""" """
class Foo(object): class Foo(object):
foo = 'bar' foo = 'bar'
context = Context.create(Foo()) context = ContextStack.create(Foo())
self.assertEqual(context.get('foo'), 'bar') self.assertEqual(context.get('foo'), 'bar')
def test_create__context(self): def test_create__context(self):
""" """
Test passing a Context instance. Test passing a ContextStack instance.
""" """
obj = Context({'foo': 'bar'}) obj = ContextStack({'foo': 'bar'})
context = Context.create(obj) context = ContextStack.create(obj)
self.assertEqual(context.get('foo'), 'bar') self.assertEqual(context.get('foo'), 'bar')
def test_create__kwarg(self): def test_create__kwarg(self):
...@@ -287,7 +287,7 @@ class ContextTests(unittest.TestCase, AssertIsMixin): ...@@ -287,7 +287,7 @@ class ContextTests(unittest.TestCase, AssertIsMixin):
Test passing a keyword argument. Test passing a keyword argument.
""" """
context = Context.create(foo='bar') context = ContextStack.create(foo='bar')
self.assertEqual(context.get('foo'), 'bar') self.assertEqual(context.get('foo'), 'bar')
def test_create__precedence_positional(self): def test_create__precedence_positional(self):
...@@ -295,7 +295,7 @@ class ContextTests(unittest.TestCase, AssertIsMixin): ...@@ -295,7 +295,7 @@ class ContextTests(unittest.TestCase, AssertIsMixin):
Test precedence of positional arguments. Test precedence of positional arguments.
""" """
context = Context.create({'foo': 'bar'}, {'foo': 'buzz'}) context = ContextStack.create({'foo': 'bar'}, {'foo': 'buzz'})
self.assertEqual(context.get('foo'), 'buzz') self.assertEqual(context.get('foo'), 'buzz')
def test_create__precedence_keyword(self): def test_create__precedence_keyword(self):
...@@ -303,7 +303,7 @@ class ContextTests(unittest.TestCase, AssertIsMixin): ...@@ -303,7 +303,7 @@ class ContextTests(unittest.TestCase, AssertIsMixin):
Test precedence of keyword arguments. Test precedence of keyword arguments.
""" """
context = Context.create({'foo': 'bar'}, foo='buzz') context = ContextStack.create({'foo': 'bar'}, foo='buzz')
self.assertEqual(context.get('foo'), 'buzz') self.assertEqual(context.get('foo'), 'buzz')
def test_get__key_present(self): def test_get__key_present(self):
...@@ -311,7 +311,7 @@ class ContextTests(unittest.TestCase, AssertIsMixin): ...@@ -311,7 +311,7 @@ class ContextTests(unittest.TestCase, AssertIsMixin):
Test getting a key. Test getting a key.
""" """
context = Context({"foo": "bar"}) context = ContextStack({"foo": "bar"})
self.assertEqual(context.get("foo"), "bar") self.assertEqual(context.get("foo"), "bar")
def test_get__key_missing(self): def test_get__key_missing(self):
...@@ -319,15 +319,15 @@ class ContextTests(unittest.TestCase, AssertIsMixin): ...@@ -319,15 +319,15 @@ class ContextTests(unittest.TestCase, AssertIsMixin):
Test getting a missing key. Test getting a missing key.
""" """
context = Context() context = ContextStack()
self.assertTrue(context.get("foo") is None) self.assertString(context.get("foo"), u'')
def test_get__default(self): def test_get__default(self):
""" """
Test that get() respects the default value. Test that get() respects the default value.
""" """
context = Context() context = ContextStack()
self.assertEqual(context.get("foo", "bar"), "bar") self.assertEqual(context.get("foo", "bar"), "bar")
def test_get__precedence(self): def test_get__precedence(self):
...@@ -335,7 +335,7 @@ class ContextTests(unittest.TestCase, AssertIsMixin): ...@@ -335,7 +335,7 @@ class ContextTests(unittest.TestCase, AssertIsMixin):
Test that get() respects the order of precedence (later items first). Test that get() respects the order of precedence (later items first).
""" """
context = Context({"foo": "bar"}, {"foo": "buzz"}) context = ContextStack({"foo": "bar"}, {"foo": "buzz"})
self.assertEqual(context.get("foo"), "buzz") self.assertEqual(context.get("foo"), "buzz")
def test_get__fallback(self): def test_get__fallback(self):
...@@ -343,7 +343,7 @@ class ContextTests(unittest.TestCase, AssertIsMixin): ...@@ -343,7 +343,7 @@ class ContextTests(unittest.TestCase, AssertIsMixin):
Check that first-added stack items are queried on context misses. Check that first-added stack items are queried on context misses.
""" """
context = Context({"fuzz": "buzz"}, {"foo": "bar"}) context = ContextStack({"fuzz": "buzz"}, {"foo": "bar"})
self.assertEqual(context.get("fuzz"), "buzz") self.assertEqual(context.get("fuzz"), "buzz")
def test_push(self): def test_push(self):
...@@ -352,7 +352,7 @@ class ContextTests(unittest.TestCase, AssertIsMixin): ...@@ -352,7 +352,7 @@ class ContextTests(unittest.TestCase, AssertIsMixin):
""" """
key = "foo" key = "foo"
context = Context({key: "bar"}) context = ContextStack({key: "bar"})
self.assertEqual(context.get(key), "bar") self.assertEqual(context.get(key), "bar")
context.push({key: "buzz"}) context.push({key: "buzz"})
...@@ -364,7 +364,7 @@ class ContextTests(unittest.TestCase, AssertIsMixin): ...@@ -364,7 +364,7 @@ class ContextTests(unittest.TestCase, AssertIsMixin):
""" """
key = "foo" key = "foo"
context = Context({key: "bar"}, {key: "buzz"}) context = ContextStack({key: "bar"}, {key: "buzz"})
self.assertEqual(context.get(key), "buzz") self.assertEqual(context.get(key), "buzz")
item = context.pop() item = context.pop()
...@@ -373,7 +373,7 @@ class ContextTests(unittest.TestCase, AssertIsMixin): ...@@ -373,7 +373,7 @@ class ContextTests(unittest.TestCase, AssertIsMixin):
def test_top(self): def test_top(self):
key = "foo" key = "foo"
context = Context({key: "bar"}, {key: "buzz"}) context = ContextStack({key: "bar"}, {key: "buzz"})
self.assertEqual(context.get(key), "buzz") self.assertEqual(context.get(key), "buzz")
top = context.top() top = context.top()
...@@ -383,7 +383,7 @@ class ContextTests(unittest.TestCase, AssertIsMixin): ...@@ -383,7 +383,7 @@ class ContextTests(unittest.TestCase, AssertIsMixin):
def test_copy(self): def test_copy(self):
key = "foo" key = "foo"
original = Context({key: "bar"}, {key: "buzz"}) original = ContextStack({key: "bar"}, {key: "buzz"})
self.assertEqual(original.get(key), "buzz") self.assertEqual(original.get(key), "buzz")
new = original.copy() new = original.copy()
...@@ -395,3 +395,76 @@ class ContextTests(unittest.TestCase, AssertIsMixin): ...@@ -395,3 +395,76 @@ class ContextTests(unittest.TestCase, AssertIsMixin):
# Confirm the original is unchanged. # Confirm the original is unchanged.
self.assertEqual(original.get(key), "buzz") self.assertEqual(original.get(key), "buzz")
def test_dot_notation__dict(self):
name = "foo.bar"
stack = ContextStack({"foo": {"bar": "baz"}})
self.assertEqual(stack.get(name), "baz")
# Works all the way down
name = "a.b.c.d.e.f.g"
stack = ContextStack({"a": {"b": {"c": {"d": {"e": {"f": {"g": "w00t!"}}}}}}})
self.assertEqual(stack.get(name), "w00t!")
def test_dot_notation__user_object(self):
name = "foo.bar"
stack = ContextStack({"foo": Attachable(bar="baz")})
self.assertEquals(stack.get(name), "baz")
# Works on multiple levels, too
name = "a.b.c.d.e.f.g"
A = Attachable
stack = ContextStack({"a": A(b=A(c=A(d=A(e=A(f=A(g="w00t!"))))))})
self.assertEquals(stack.get(name), "w00t!")
def test_dot_notation__mixed_dict_and_obj(self):
name = "foo.bar.baz.bak"
stack = ContextStack({"foo": Attachable(bar={"baz": Attachable(bak=42)})})
self.assertEquals(stack.get(name), 42)
def test_dot_notation__missing_attr_or_key(self):
name = "foo.bar.baz.bak"
stack = ContextStack({"foo": {"bar": {}}})
self.assertString(stack.get(name), u'')
stack = ContextStack({"foo": Attachable(bar=Attachable())})
self.assertString(stack.get(name), u'')
def test_dot_notation__missing_part_terminates_search(self):
"""
Test that dotted name resolution terminates on a later part not found.
Check that if a later dotted name part is not found in the result from
the former resolution, then name resolution terminates rather than
starting the search over with the next element of the context stack.
From the spec (interpolation section)--
5) If any name parts were retained in step 1, each should be resolved
against a context stack containing only the result from the former
resolution. If any part fails resolution, the result should be considered
falsey, and should interpolate as the empty string.
This test case is equivalent to the test case in the following pull
request:
https://github.com/mustache/spec/pull/48
"""
stack = ContextStack({'a': {'b': 'A.B'}}, {'a': 'A'})
self.assertEqual(stack.get('a'), 'A')
self.assertString(stack.get('a.b'), u'')
stack.pop()
self.assertEqual(stack.get('a.b'), 'A.B')
def test_dot_notation__autocall(self):
name = "foo.bar.baz"
# When any element in the path is callable, it should be automatically invoked
stack = ContextStack({"foo": Attachable(bar=Attachable(baz=lambda: "Called!"))})
self.assertEquals(stack.get(name), "Called!")
class Foo(object):
def bar(self):
return Attachable(baz='Baz')
stack = ContextStack({"foo": Foo()})
self.assertEquals(stack.get(name), "Baz")
...@@ -11,14 +11,15 @@ import sys ...@@ -11,14 +11,15 @@ import sys
import unittest import unittest
# TODO: remove this alias. # TODO: remove this alias.
from pystache.common import TemplateNotFoundError
from pystache.loader import Loader as Reader from pystache.loader import Loader as Reader
from pystache.locator import Locator from pystache.locator import Locator
from pystache.tests.common import DATA_DIR, EXAMPLES_DIR from pystache.tests.common import DATA_DIR, EXAMPLES_DIR, AssertExceptionMixin
from pystache.tests.data.views import SayHello from pystache.tests.data.views import SayHello
class LocatorTests(unittest.TestCase): class LocatorTests(unittest.TestCase, AssertExceptionMixin):
def _locator(self): def _locator(self):
return Locator(search_dirs=DATA_DIR) return Locator(search_dirs=DATA_DIR)
...@@ -110,7 +111,8 @@ class LocatorTests(unittest.TestCase): ...@@ -110,7 +111,8 @@ class LocatorTests(unittest.TestCase):
def test_find_name__non_existent_template_fails(self): def test_find_name__non_existent_template_fails(self):
locator = Locator() locator = Locator()
self.assertRaises(IOError, locator.find_name, search_dirs=[], template_name='doesnt_exist') self.assertException(TemplateNotFoundError, "File 'doesnt_exist.mustache' not found in dirs: []",
locator.find_name, search_dirs=[], template_name='doesnt_exist')
def test_find_object(self): def test_find_object(self):
locator = Locator() locator = Locator()
......
# coding: utf-8
"""
Unit tests of parser.py.
"""
import unittest
from pystache.parser import _compile_template_re as make_re
class RegularExpressionTestCase(unittest.TestCase):
"""Tests the regular expression returned by _compile_template_re()."""
def test_re(self):
"""
Test getting a key from a dictionary.
"""
re = make_re()
match = re.search("b {{test}}")
self.assertEqual(match.start(), 1)
...@@ -7,11 +7,11 @@ Unit tests of renderengine.py. ...@@ -7,11 +7,11 @@ Unit tests of renderengine.py.
import unittest import unittest
from pystache.context import Context from pystache.context import ContextStack
from pystache import defaults from pystache import defaults
from pystache.parser import ParsingError from pystache.parser import ParsingError
from pystache.renderengine import RenderEngine from pystache.renderengine import RenderEngine
from pystache.tests.common import AssertStringMixin from pystache.tests.common import AssertStringMixin, Attachable
def mock_literal(s): def mock_literal(s):
...@@ -83,7 +83,7 @@ class RenderTests(unittest.TestCase, AssertStringMixin): ...@@ -83,7 +83,7 @@ class RenderTests(unittest.TestCase, AssertStringMixin):
if partials is not None: if partials is not None:
engine.load_partial = lambda key: unicode(partials[key]) engine.load_partial = lambda key: unicode(partials[key])
context = Context(*context) context = ContextStack(*context)
actual = engine.render(template, context) actual = engine.render(template, context)
...@@ -204,6 +204,27 @@ class RenderTests(unittest.TestCase, AssertStringMixin): ...@@ -204,6 +204,27 @@ class RenderTests(unittest.TestCase, AssertStringMixin):
context = {'test': '{{#hello}}'} context = {'test': '{{#hello}}'}
self._assert_render(u'{{#hello}}', template, context) self._assert_render(u'{{#hello}}', template, context)
## Test interpolation with "falsey" values
#
# In these test cases, we test the part of the spec that says that
# "data should be coerced into a string (and escaped, if appropriate)
# before interpolation." We test this for data that is "falsey."
def test_interpolation__falsey__zero(self):
template = '{{.}}'
context = 0
self._assert_render(u'0', template, context)
def test_interpolation__falsey__none(self):
template = '{{.}}'
context = None
self._assert_render(u'None', template, context)
def test_interpolation__falsey__zero(self):
template = '{{.}}'
context = False
self._assert_render(u'False', template, context)
# Built-in types: # Built-in types:
# #
# Confirm that we not treat instances of built-in types as objects, # Confirm that we not treat instances of built-in types as objects,
...@@ -480,6 +501,56 @@ class RenderTests(unittest.TestCase, AssertStringMixin): ...@@ -480,6 +501,56 @@ class RenderTests(unittest.TestCase, AssertStringMixin):
context = {'person': 'Mom', 'test': (lambda text: text + " :)")} context = {'person': 'Mom', 'test': (lambda text: text + " :)")}
self._assert_render(u'Hi Mom :)', template, context) self._assert_render(u'Hi Mom :)', template, context)
def test_section__lambda__list(self):
"""
Check that lists of lambdas are processed correctly for sections.
This test case is equivalent to a test submitted to the Mustache spec here:
https://github.com/mustache/spec/pull/47 .
"""
template = '<{{#lambdas}}foo{{/lambdas}}>'
context = {'foo': 'bar',
'lambdas': [lambda text: "~{{%s}}~" % text,
lambda text: "#{{%s}}#" % text]}
self._assert_render(u'<~bar~#bar#>', template, context)
def test_section__lambda__not_on_context_stack(self):
"""
Check that section lambdas are not pushed onto the context stack.
Even though the sections spec says that section data values should be
pushed onto the context stack prior to rendering, this does not apply
to lambdas. Lambdas obey their own special case.
This test case is equivalent to a test submitted to the Mustache spec here:
https://github.com/mustache/spec/pull/47 .
"""
context = {'foo': 'bar', 'lambda': (lambda text: "{{.}}")}
template = '{{#foo}}{{#lambda}}blah{{/lambda}}{{/foo}}'
self._assert_render(u'bar', template, context)
def test_section__lambda__no_reinterpolation(self):
"""
Check that section lambda return values are not re-interpolated.
This test is a sanity check that the rendered lambda return value
is not re-interpolated as could be construed by reading the
section part of the Mustache spec.
This test case is equivalent to a test submitted to the Mustache spec here:
https://github.com/mustache/spec/pull/47 .
"""
template = '{{#planet}}{{#lambda}}dot{{/lambda}}{{/planet}}'
context = {'planet': 'Earth', 'dot': '~{{.}}~', 'lambda': (lambda text: "#{{%s}}#" % text)}
self._assert_render(u'#~{{.}}~#', template, context)
def test_comment__multiline(self): def test_comment__multiline(self):
""" """
Check that multiline comments are permitted. Check that multiline comments are permitted.
...@@ -509,3 +580,78 @@ class RenderTests(unittest.TestCase, AssertStringMixin): ...@@ -509,3 +580,78 @@ class RenderTests(unittest.TestCase, AssertStringMixin):
expected = u' {{foo}} ' expected = u' {{foo}} '
self._assert_render(expected, '{{=$ $=}} {{foo}} ') self._assert_render(expected, '{{=$ $=}} {{foo}} ')
self._assert_render(expected, '{{=$ $=}} {{foo}} $={{ }}=$') # was yielding u' '. self._assert_render(expected, '{{=$ $=}} {{foo}} $={{ }}=$') # was yielding u' '.
def test_dot_notation(self):
"""
Test simple dot notation cases.
Check that we can use dot notation when the variable is a dict,
user-defined object, or combination of both.
"""
template = 'Hello, {{person.name}}. I see you are {{person.details.age}}.'
person = Attachable(name='Biggles', details={'age': 42})
context = {'person': person}
self._assert_render(u'Hello, Biggles. I see you are 42.', template, context)
def test_dot_notation__missing_attributes_or_keys(self):
"""
Test dot notation with missing keys or attributes.
Check that if a key or attribute in a dotted name does not exist, then
the tag renders as the empty string.
"""
template = """I cannot see {{person.name}}'s age: {{person.age}}.
Nor {{other_person.name}}'s: ."""
expected = u"""I cannot see Biggles's age: .
Nor Mr. Bradshaw's: ."""
context = {'person': {'name': 'Biggles'},
'other_person': Attachable(name='Mr. Bradshaw')}
self._assert_render(expected, template, context)
def test_dot_notation__multiple_levels(self):
"""
Test dot notation with multiple levels.
"""
template = """Hello, Mr. {{person.name.lastname}}.
I see you're back from {{person.travels.last.country.city}}.
I'm missing some of your details: {{person.details.private.editor}}."""
expected = u"""Hello, Mr. Pither.
I see you're back from Cornwall.
I'm missing some of your details: ."""
context = {'person': {'name': {'firstname': 'unknown', 'lastname': 'Pither'},
'travels': {'last': {'country': {'city': 'Cornwall'}}},
'details': {'public': 'likes cycling'}}}
self._assert_render(expected, template, context)
# It should also work with user-defined objects
context = {'person': Attachable(name={'firstname': 'unknown', 'lastname': 'Pither'},
travels=Attachable(last=Attachable(country=Attachable(city='Cornwall'))),
details=Attachable())}
self._assert_render(expected, template, context)
def test_dot_notation__missing_part_terminates_search(self):
"""
Test that dotted name resolution terminates on a later part not found.
Check that if a later dotted name part is not found in the result from
the former resolution, then name resolution terminates rather than
starting the search over with the next element of the context stack.
From the spec (interpolation section)--
5) If any name parts were retained in step 1, each should be resolved
against a context stack containing only the result from the former
resolution. If any part fails resolution, the result should be considered
falsey, and should interpolate as the empty string.
This test case is equivalent to the test case in the following pull
request:
https://github.com/mustache/spec/pull/48
"""
template = '{{a.b}} :: ({{#c}}{{a}} :: {{a.b}}{{/c}})'
context = {'a': {'b': 'A.B'}, 'c': {'a': 'A'} }
self._assert_render(u'A.B :: (A :: )', template, context)
...@@ -13,9 +13,10 @@ import unittest ...@@ -13,9 +13,10 @@ import unittest
from examples.simple import Simple from examples.simple import Simple
from pystache import Renderer from pystache import Renderer
from pystache import TemplateSpec from pystache import TemplateSpec
from pystache.common import TemplateNotFoundError
from pystache.loader import Loader from pystache.loader import Loader
from pystache.tests.common import get_data_path, AssertStringMixin from pystache.tests.common import get_data_path, AssertStringMixin, AssertExceptionMixin
from pystache.tests.data.views import SayHello from pystache.tests.data.views import SayHello
...@@ -405,7 +406,7 @@ class RendererTests(unittest.TestCase, AssertStringMixin): ...@@ -405,7 +406,7 @@ class RendererTests(unittest.TestCase, AssertStringMixin):
# we no longer need to exercise all rendering code paths through # we no longer need to exercise all rendering code paths through
# the Renderer. It suffices to test rendering paths through the # the Renderer. It suffices to test rendering paths through the
# RenderEngine for the same amount of code coverage. # RenderEngine for the same amount of code coverage.
class Renderer_MakeRenderEngineTests(unittest.TestCase): class Renderer_MakeRenderEngineTests(unittest.TestCase, AssertExceptionMixin):
""" """
Check the RenderEngine returned by Renderer._make_render_engine(). Check the RenderEngine returned by Renderer._make_render_engine().
...@@ -444,7 +445,20 @@ class Renderer_MakeRenderEngineTests(unittest.TestCase): ...@@ -444,7 +445,20 @@ class Renderer_MakeRenderEngineTests(unittest.TestCase):
self.assertEqual(actual, "abc") self.assertEqual(actual, "abc")
self.assertEqual(type(actual), unicode) self.assertEqual(type(actual), unicode)
def test__load_partial__not_found(self): def test__load_partial__not_found__default(self):
"""
Check that load_partial provides a nice message when a template is not found.
"""
renderer = Renderer()
engine = renderer._make_render_engine()
load_partial = engine.load_partial
self.assertException(TemplateNotFoundError, "File 'foo.mustache' not found in dirs: ['.']",
load_partial, "foo")
def test__load_partial__not_found__dict(self):
""" """
Check that load_partial provides a nice message when a template is not found. Check that load_partial provides a nice message when a template is not found.
...@@ -455,11 +469,10 @@ class Renderer_MakeRenderEngineTests(unittest.TestCase): ...@@ -455,11 +469,10 @@ class Renderer_MakeRenderEngineTests(unittest.TestCase):
engine = renderer._make_render_engine() engine = renderer._make_render_engine()
load_partial = engine.load_partial load_partial = engine.load_partial
try: # Include dict directly since str(dict) is different in Python 2 and 3:
load_partial("foo") # <type 'dict'> versus <class 'dict'>, respectively.
raise Exception("Shouldn't get here") self.assertException(TemplateNotFoundError, "Name 'foo' not found in partials: %s" % dict,
except Exception, err: load_partial, "foo")
self.assertEqual(str(err), "Partial not found with name: 'foo'")
## Test the engine's literal attribute. ## Test the engine's literal attribute.
......
...@@ -16,6 +16,7 @@ from examples.lambdas import Lambdas ...@@ -16,6 +16,7 @@ from examples.lambdas import Lambdas
from examples.inverted import Inverted, InvertedLists from examples.inverted import Inverted, InvertedLists
from pystache import Renderer from pystache import Renderer
from pystache import TemplateSpec from pystache import TemplateSpec
from pystache.common import TemplateNotFoundError
from pystache.locator import Locator from pystache.locator import Locator
from pystache.loader import Loader from pystache.loader import Loader
from pystache.specloader import SpecLoader from pystache.specloader import SpecLoader
...@@ -42,7 +43,7 @@ class ViewTestCase(unittest.TestCase, AssertStringMixin): ...@@ -42,7 +43,7 @@ class ViewTestCase(unittest.TestCase, AssertStringMixin):
view = Tagless() view = Tagless()
renderer = Renderer() renderer = Renderer()
self.assertRaises(IOError, renderer.render, view) self.assertRaises(TemplateNotFoundError, renderer.render, view)
# TODO: change this test to remove the following brittle line. # TODO: change this test to remove the following brittle line.
view.template_rel_directory = "examples" view.template_rel_directory = "examples"
...@@ -60,7 +61,8 @@ class ViewTestCase(unittest.TestCase, AssertStringMixin): ...@@ -60,7 +61,8 @@ class ViewTestCase(unittest.TestCase, AssertStringMixin):
renderer1 = Renderer() renderer1 = Renderer()
renderer2 = Renderer(search_dirs=EXAMPLES_DIR) renderer2 = Renderer(search_dirs=EXAMPLES_DIR)
self.assertRaises(IOError, renderer1.render, spec) actual = renderer1.render(spec)
self.assertString(actual, u"Partial: ")
actual = renderer2.render(spec) actual = renderer2.render(spec)
self.assertEqual(actual, "Partial: No tags...") self.assertEqual(actual, "Partial: No tags...")
......
...@@ -4,19 +4,21 @@ ...@@ -4,19 +4,21 @@
""" """
This script supports publishing Pystache to PyPI. This script supports publishing Pystache to PyPI.
Below are instructions to pystache maintainers on how to push a new This docstring contains instructions to Pystache maintainers on how
version of pystache to PyPI-- to release a new version of Pystache.
(1) Push to PyPI. To release a new version of Pystache to PyPI--
http://pypi.python.org/pypi/pystache http://pypi.python.org/pypi/pystache
Create a PyPI user account. The user account will need permissions to push create a PyPI user account if you do not already have one. The user account
to PyPI. A current "Package Index Owner" of pystache can grant you those will need permissions to push to PyPI. A current "Package Index Owner" of
permissions. Pystache can grant you those permissions.
When you have permissions, run the following (after preparing the release, When you have permissions, run the following (after preparing the release,
bumping the version number in setup.py, etc): merging to master, bumping the version number in setup.py, etc):
> python setup.py publish python setup.py publish
If you get an error like the following-- If you get an error like the following--
...@@ -33,6 +35,20 @@ as described here, for example: ...@@ -33,6 +35,20 @@ as described here, for example:
http://docs.python.org/release/2.5.2/dist/pypirc.html http://docs.python.org/release/2.5.2/dist/pypirc.html
(2) Tag the release on GitHub. Here are some commands for tagging.
List current tags:
git tag -l -n3
Create an annotated tag:
git tag -a -m "Version 0.5.1" "v0.5.1"
Push a tag to GitHub:
git push --tags defunkt v0.5.1
""" """
import os import os
...@@ -56,7 +72,7 @@ else: ...@@ -56,7 +72,7 @@ else:
# print("Using: version %s of %s" % (repr(dist.__version__), repr(dist))) # print("Using: version %s of %s" % (repr(dist.__version__), repr(dist)))
VERSION = '0.5.1' # Also change in pystache/__init__.py. VERSION = '0.5.2-rc' # Also change in pystache/__init__.py.
HISTORY_PATH = 'HISTORY.rst' HISTORY_PATH = 'HISTORY.rst'
LICENSE_PATH = 'LICENSE' LICENSE_PATH = 'LICENSE'
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment