diff options
author | Chris Jerdonek <chris.jerdonek@gmail.com> | 2012-05-02 19:17:54 -0700 |
---|---|---|
committer | Chris Jerdonek <chris.jerdonek@gmail.com> | 2012-05-02 19:17:54 -0700 |
commit | 384475a56222360331683f2ceb3ecd8d38bf5c7e (patch) | |
tree | 2ab26250ee943abd89b110403c7eeee2cf99d172 | |
parent | 29ac86c0dec2f394f80f076ba3e8c996aa65026c (diff) | |
parent | bd6b5f04fa156b00d2b2831f14f9bdecfbdb8e2a (diff) | |
download | pystache-384475a56222360331683f2ceb3ecd8d38bf5c7e.tar.gz |
Merge branch 'issue-99-dot-notation' into development:
Addresses issue #99: "Mustache spec v1.1.2 compliance"
-rw-r--r-- | HISTORY.rst | 6 | ||||
-rw-r--r-- | README.rst | 4 | ||||
m--------- | ext/spec | 0 | ||||
-rw-r--r-- | pystache/context.py | 94 | ||||
-rw-r--r-- | pystache/renderengine.py | 3 | ||||
-rw-r--r-- | pystache/tests/common.py | 25 | ||||
-rw-r--r-- | pystache/tests/test_context.py | 79 | ||||
-rw-r--r-- | pystache/tests/test_renderengine.py | 77 |
8 files changed, 250 insertions, 38 deletions
diff --git a/HISTORY.rst b/HISTORY.rst index 1b3dcd2..a281594 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -4,10 +4,10 @@ History 0.6.0 (TBD) ----------- +* Added support for dot notation and version 1.1.2 of the spec (issue #99). [rbp] * Bugfix: falsey values now coerced to strings using str(). -* Bugfix: issue #113: lambda return values for sections no longer pushed - onto context stack. -* Bugfix: issue #114: lists of lambdas for sections were not rendered. +* 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) ------------------ @@ -15,7 +15,7 @@ syntax. For a more complete (and more current) description of Mustache's behavior, see the official `Mustache spec`_. 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`_ @@ -230,5 +230,5 @@ Authors .. _TemplateSpec: https://github.com/defunkt/pystache/blob/master/pystache/template_spec.py .. _test: http://packages.python.org/distribute/setuptools.html#test .. _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 diff --git a/ext/spec b/ext/spec -Subproject 48c933b0bb780875acbfd15816297e263c53d6f +Subproject bf6288ed6bd0ce8ccea6f1dac070b3d779132c3 diff --git a/pystache/context.py b/pystache/context.py index de22a75..403694c 100644 --- a/pystache/context.py +++ b/pystache/context.py @@ -1,7 +1,7 @@ # coding: utf-8 """ -Exposes a ContextStack class and functions to retrieve names from context. +Exposes a ContextStack class. """ @@ -27,6 +27,8 @@ def _is_callable(obj): return hasattr(obj, '__call__') +# TODO: rename item to context (now that we have a separate notion of context stack). +# TODO: document what a "context" is as opposed to a context stack. def _get_value(item, key): """ Retrieve a key's value from an item. @@ -60,25 +62,6 @@ def _get_value(item, key): return _NOT_FOUND -# TODO: add some unit tests for this. -def resolve(context, name): - """ - Resolve the given name against the given context stack. - - This function follows the rules outlined in the section of the spec - regarding tag interpolation. - - This function does not coerce the return value to a string. - - """ - if name == '.': - return context.top() - - # The spec says that if the name fails resolution, the result should be - # considered falsey, and should interpolate as the empty string. - return context.get(name, '') - - class ContextStack(object): """ @@ -186,9 +169,65 @@ class ContextStack(object): return context - def get(self, key, default=None): + # TODO: add some unit tests for this. + 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. + + 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 since the Mustache spec says that if + name resolution fails at any point, the result should be considered + falsey, and should interpolate as the empty string. + + This function does not coerce the return value to a string. + + """ + if name == '.': + # TODO: should we add a test case for an empty context stack? + return self.top() + + parts = name.split('.') + + value = 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 value 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. + value = _get_value(value, part) + + if value is _NOT_FOUND: + return default + + return value + + # TODO: combine the docstring for this method with the docstring for + # the get() method. + def _get_simple(self, key): + """ + Query the stack for a non-dotted key, and return the resulting value. + + Arguments: + + key: a non-dotted name. This method queries items in the stack in order from last-added objects to first (last in, first out). The value returned is @@ -253,15 +292,16 @@ class ContextStack(object): TODO: explain the rationale for this difference in treatment. """ - for obj in reversed(self._stack): - val = _get_value(obj, key) + val = _NOT_FOUND + + for item in reversed(self._stack): + val = _get_value(item, key) if val is _NOT_FOUND: continue # Otherwise, the key was found. - return val - # Otherwise, no item in the stack contained the key. + break - return default + return val def push(self, item): """ diff --git a/pystache/renderengine.py b/pystache/renderengine.py index 0ce708b..b05e022 100644 --- a/pystache/renderengine.py +++ b/pystache/renderengine.py @@ -7,7 +7,6 @@ Defines a class responsible for rendering logic. import re -from pystache.context import resolve from pystache.parser import Parser @@ -69,7 +68,7 @@ class RenderEngine(object): Get a value from the given context as a basestring instance. """ - val = resolve(context, tag_name) + val = context.get(tag_name) if callable(val): # According to the spec: diff --git a/pystache/tests/common.py b/pystache/tests/common.py index a99e709..4c8f46c 100644 --- a/pystache/tests/common.py +++ b/pystache/tests/common.py @@ -191,3 +191,28 @@ class SetupDefaults(object): defaults.FILE_ENCODING = self.original_file_encoding defaults.STRING_ENCODING = self.original_string_encoding + +class Attachable(object): + """ + A class that attaches all constructor named parameters as attributes. + + For example-- + + >>> obj = Attachable(foo=42, size="of the universe") + >>> repr(obj) + "Attachable(foo=42, size='of the universe')" + >>> obj.foo + 42 + >>> obj.size + 'of the universe' + + """ + def __init__(self, **kwargs): + self.__args__ = kwargs + for arg, value in kwargs.iteritems(): + setattr(self, arg, value) + + def __repr__(self): + return "%s(%s)" % (self.__class__.__name__, + ", ".join("%s=%s" % (k, repr(v)) + for k, v in self.__args__.iteritems())) diff --git a/pystache/tests/test_context.py b/pystache/tests/test_context.py index dd9fdae..0c5097b 100644 --- a/pystache/tests/test_context.py +++ b/pystache/tests/test_context.py @@ -11,7 +11,7 @@ import unittest from pystache.context import _NOT_FOUND from pystache.context import _get_value from pystache.context import ContextStack -from pystache.tests.common import AssertIsMixin +from pystache.tests.common import AssertIsMixin, AssertStringMixin, Attachable class SimpleObject(object): @@ -204,7 +204,7 @@ class GetValueTests(unittest.TestCase, AssertIsMixin): self.assertNotFound(item2, 'pop') -class ContextStackTests(unittest.TestCase, AssertIsMixin): +class ContextStackTests(unittest.TestCase, AssertIsMixin, AssertStringMixin): """ Test the ContextStack class. @@ -320,7 +320,7 @@ class ContextStackTests(unittest.TestCase, AssertIsMixin): """ context = ContextStack() - self.assertTrue(context.get("foo") is None) + self.assertString(context.get("foo"), u'') def test_get__default(self): """ @@ -395,3 +395,76 @@ class ContextStackTests(unittest.TestCase, AssertIsMixin): # Confirm the original is unchanged. self.assertEqual(original.get(key), "buzz") + def test_dot_notation__dict(self): + name = "foo.bar" + stack = ContextStack({"foo": {"bar": "baz"}}) + self.assertEqual(stack.get(name), "baz") + + # Works all the way down + name = "a.b.c.d.e.f.g" + stack = ContextStack({"a": {"b": {"c": {"d": {"e": {"f": {"g": "w00t!"}}}}}}}) + self.assertEqual(stack.get(name), "w00t!") + + def test_dot_notation__user_object(self): + name = "foo.bar" + stack = ContextStack({"foo": Attachable(bar="baz")}) + self.assertEquals(stack.get(name), "baz") + + # Works on multiple levels, too + name = "a.b.c.d.e.f.g" + A = Attachable + stack = ContextStack({"a": A(b=A(c=A(d=A(e=A(f=A(g="w00t!"))))))}) + self.assertEquals(stack.get(name), "w00t!") + + def test_dot_notation__mixed_dict_and_obj(self): + name = "foo.bar.baz.bak" + stack = ContextStack({"foo": Attachable(bar={"baz": Attachable(bak=42)})}) + self.assertEquals(stack.get(name), 42) + + def test_dot_notation__missing_attr_or_key(self): + name = "foo.bar.baz.bak" + stack = ContextStack({"foo": {"bar": {}}}) + self.assertString(stack.get(name), u'') + + stack = ContextStack({"foo": Attachable(bar=Attachable())}) + self.assertString(stack.get(name), u'') + + def test_dot_notation__missing_part_terminates_search(self): + """ + Test that dotted name resolution terminates on a later part not found. + + Check that if a later dotted name part is not found in the result from + the former resolution, then name resolution terminates rather than + starting the search over with the next element of the context stack. + From the spec (interpolation section)-- + + 5) If any name parts were retained in step 1, each should be resolved + against a context stack containing only the result from the former + resolution. If any part fails resolution, the result should be considered + falsey, and should interpolate as the empty string. + + This test case is equivalent to the test case in the following pull + request: + + https://github.com/mustache/spec/pull/48 + + """ + stack = ContextStack({'a': {'b': 'A.B'}}, {'a': 'A'}) + self.assertEqual(stack.get('a'), 'A') + self.assertString(stack.get('a.b'), u'') + stack.pop() + self.assertEqual(stack.get('a.b'), 'A.B') + + def test_dot_notation__autocall(self): + name = "foo.bar.baz" + + # When any element in the path is callable, it should be automatically invoked + stack = ContextStack({"foo": Attachable(bar=Attachable(baz=lambda: "Called!"))}) + self.assertEquals(stack.get(name), "Called!") + + class Foo(object): + def bar(self): + return Attachable(baz='Baz') + + stack = ContextStack({"foo": Foo()}) + self.assertEquals(stack.get(name), "Baz") diff --git a/pystache/tests/test_renderengine.py b/pystache/tests/test_renderengine.py index 667fa03..d7f6bf7 100644 --- a/pystache/tests/test_renderengine.py +++ b/pystache/tests/test_renderengine.py @@ -11,7 +11,7 @@ from pystache.context import ContextStack from pystache import defaults from pystache.parser import ParsingError from pystache.renderengine import RenderEngine -from pystache.tests.common import AssertStringMixin +from pystache.tests.common import AssertStringMixin, Attachable def mock_literal(s): @@ -580,3 +580,78 @@ class RenderTests(unittest.TestCase, AssertStringMixin): expected = u' {{foo}} ' self._assert_render(expected, '{{=$ $=}} {{foo}} ') self._assert_render(expected, '{{=$ $=}} {{foo}} $={{ }}=$') # was yielding u' '. + + def test_dot_notation(self): + """ + Test simple dot notation cases. + + Check that we can use dot notation when the variable is a dict, + user-defined object, or combination of both. + + """ + template = 'Hello, {{person.name}}. I see you are {{person.details.age}}.' + person = Attachable(name='Biggles', details={'age': 42}) + context = {'person': person} + self._assert_render(u'Hello, Biggles. I see you are 42.', template, context) + + def test_dot_notation__missing_attributes_or_keys(self): + """ + Test dot notation with missing keys or attributes. + + Check that if a key or attribute in a dotted name does not exist, then + the tag renders as the empty string. + + """ + template = """I cannot see {{person.name}}'s age: {{person.age}}. + Nor {{other_person.name}}'s: .""" + expected = u"""I cannot see Biggles's age: . + Nor Mr. Bradshaw's: .""" + context = {'person': {'name': 'Biggles'}, + 'other_person': Attachable(name='Mr. Bradshaw')} + self._assert_render(expected, template, context) + + def test_dot_notation__multiple_levels(self): + """ + Test dot notation with multiple levels. + + """ + template = """Hello, Mr. {{person.name.lastname}}. + I see you're back from {{person.travels.last.country.city}}. + I'm missing some of your details: {{person.details.private.editor}}.""" + expected = u"""Hello, Mr. Pither. + I see you're back from Cornwall. + I'm missing some of your details: .""" + context = {'person': {'name': {'firstname': 'unknown', 'lastname': 'Pither'}, + 'travels': {'last': {'country': {'city': 'Cornwall'}}}, + 'details': {'public': 'likes cycling'}}} + self._assert_render(expected, template, context) + + # It should also work with user-defined objects + context = {'person': Attachable(name={'firstname': 'unknown', 'lastname': 'Pither'}, + travels=Attachable(last=Attachable(country=Attachable(city='Cornwall'))), + details=Attachable())} + self._assert_render(expected, template, context) + + def test_dot_notation__missing_part_terminates_search(self): + """ + Test that dotted name resolution terminates on a later part not found. + + Check that if a later dotted name part is not found in the result from + the former resolution, then name resolution terminates rather than + starting the search over with the next element of the context stack. + From the spec (interpolation section)-- + + 5) If any name parts were retained in step 1, each should be resolved + against a context stack containing only the result from the former + resolution. If any part fails resolution, the result should be considered + falsey, and should interpolate as the empty string. + + This test case is equivalent to the test case in the following pull + request: + + https://github.com/mustache/spec/pull/48 + + """ + template = '{{a.b}} :: ({{#c}}{{a}} :: {{a.b}}{{/c}})' + context = {'a': {'b': 'A.B'}, 'c': {'a': 'A'} } + self._assert_render(u'A.B :: (A :: )', template, context) |