Commit f54f0ea7 by Chris Jerdonek

Merge branch 'issue_49' into development: closing issue #49

This merge adds 21 unit tests.
parents 117e3530 b2137d4f
...@@ -11,6 +11,6 @@ class TemplatePartial(pystache.View): ...@@ -11,6 +11,6 @@ class TemplatePartial(pystache.View):
def looping(self): def looping(self):
return [{'item': 'one'}, {'item': 'two'}, {'item': 'three'}] return [{'item': 'one'}, {'item': 'two'}, {'item': 'three'}]
def thing(self): def thing(self):
return self['prop'] return self.get('prop')
\ No newline at end of file \ No newline at end of file
# coding: utf-8
"""
Defines a Context class to represent mustache(5)'s notion of context.
"""
# 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 = object()
# TODO: share code with template.check_callable().
def _is_callable(obj):
return hasattr(obj, '__call__')
def _get_item(obj, key):
"""
Return a key's value, or _NOT_FOUND if the key does not exist.
The obj argument should satisfy the same conditions as those
described for the arguments passed to Context.__init__(). These
conditions are described in Context.__init__()'s docstring.
The rules for looking up the value of a key are the same as the rules
described in Context.get()'s docstring for querying a single item.
The behavior of this function is undefined if obj is None.
"""
if hasattr(obj, '__getitem__'):
# We do a membership test to avoid using exceptions for flow control
# (e.g. catching KeyError). In addition, we call __contains__()
# explicitly as opposed to using the membership operator "in" to
# avoid triggering the following Python fallback behavior:
#
# "For objects that don’t define __contains__(), the membership test
# first tries iteration via __iter__(), then the old sequence
# iteration protocol via __getitem__()...."
#
# (from http://docs.python.org/reference/datamodel.html#object.__contains__ )
if obj.__contains__(key):
return obj[key]
elif hasattr(obj, key):
attr = getattr(obj, 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).
Instances encapsulate a private stack of objects and dictionaries.
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).
See the docstrings of the methods of this class for more information.
"""
# 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.
Each item should satisfy the following condition:
* If the item implements __getitem__(), it should also implement
__contains__(). Failure to implement __contains__() will cause
an AttributeError to be raised when the item is queried during
calls to self.get().
Python dictionaries, in particular, satisfy this condition.
An item satisfying this condition we informally call a "mapping
object" because it shares some characteristics of the Mapping
abstract base class (ABC) in Python's collections package:
http://docs.python.org/library/collections.html#collections-abstract-base-classes
It is not necessary for an item to implement __getitem__().
In particular, an item can be an ordinary object with no
mapping-like characteristics.
"""
self._stack = list(items)
def get(self, key, default=None):
"""
Query the stack for the given key, and return the resulting value.
Querying for a key 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 for the first item for which the item
contains the key. If the key is not found in any item in the
stack, then this method returns the default value. The default
value defaults to None.
Querying an item in the stack is done in the following way:
(1) If the item defines __getitem__() and the item contains the
key (i.e. __contains__() returns True), then the corresponding
value is returned.
(2) Otherwise, the method looks for an attribute with the same
name as the key. If such an attribute exists, the value of
this attribute is returned. If the attribute is callable,
however, the attribute is first called with no arguments.
(3) If there is no attribute with the same name as the key, then
the key is considered not found in the item.
"""
for obj in reversed(self._stack):
val = _get_item(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)
...@@ -9,6 +9,7 @@ import re ...@@ -9,6 +9,7 @@ import re
import cgi import cgi
import collections import collections
from .context import Context
from .loader import Loader from .loader import Loader
...@@ -63,24 +64,36 @@ class Template(object): ...@@ -63,24 +64,36 @@ class Template(object):
def __init__(self, template=None, context=None, **kwargs): def __init__(self, template=None, context=None, **kwargs):
""" """
The **kwargs arguments are only supported if the context is The context argument can be a dictionary, View, or Context instance.
a dictionary (i.e. not a View).
""" """
from .view import View from .view import View
self.template = template
if context is None: if context is None:
context = {} context = {}
if not isinstance(context, View): view = None
# Views do not support copy() or update().
if isinstance(context, View):
view = context
context = view.context.copy()
elif isinstance(context, Context):
context = context.copy() context = context.copy()
if kwargs: else:
context.update(kwargs) # Otherwise, the context is a dictionary.
context = Context(context)
if kwargs:
context.push(kwargs)
if view is None:
view = View()
self.context = context
self.template = template
# The view attribute is used only for its load_template() method.
self.view = view
self.view = context if isinstance(context, View) else View(context=context)
self._compile_regexps() self._compile_regexps()
def _compile_regexps(self): def _compile_regexps(self):
...@@ -103,7 +116,7 @@ class Template(object): ...@@ -103,7 +116,7 @@ class Template(object):
tag = r"%(otag)s(#|=|&|!|>|\{)?(.+?)\1?%(ctag)s+" tag = r"%(otag)s(#|=|&|!|>|\{)?(.+?)\1?%(ctag)s+"
self.tag_re = re.compile(tag % tags) self.tag_re = re.compile(tag % tags)
def _render_sections(self, template, view): def _render_sections(self, template):
while True: while True:
match = self.section_re.search(template) match = self.section_re.search(template)
if match is None: if match is None:
...@@ -111,7 +124,7 @@ class Template(object): ...@@ -111,7 +124,7 @@ class Template(object):
section, section_name, inner = match.group(0, 1, 2) section, section_name, inner = match.group(0, 1, 2)
section_name = section_name.strip() section_name = section_name.strip()
it = self.view.get(section_name, None) it = self.context.get(section_name, None)
replacer = '' replacer = ''
# Callable # Callable
...@@ -156,12 +169,13 @@ class Template(object): ...@@ -156,12 +169,13 @@ class Template(object):
return template return template
def _render_dictionary(self, template, context): def _render_dictionary(self, template, context):
self.view.context_list.insert(0, context) self.context.push(context)
template = Template(template, self.view) template = Template(template, self.context)
template.view = self.view
out = template.render() out = template.render()
self.view.context_list.pop(0) self.context.pop()
return out return out
...@@ -174,7 +188,7 @@ class Template(object): ...@@ -174,7 +188,7 @@ class Template(object):
@modifiers.set(None) @modifiers.set(None)
def _render_tag(self, tag_name): def _render_tag(self, tag_name):
raw = self.view.get(tag_name, '') raw = self.context.get(tag_name, '')
# For methods with no return value # For methods with no return value
# #
...@@ -184,7 +198,7 @@ class Template(object): ...@@ -184,7 +198,7 @@ class Template(object):
# See issue #34: https://github.com/defunkt/pystache/issues/34 # See issue #34: https://github.com/defunkt/pystache/issues/34
if not raw and raw != 0: if not raw and raw != 0:
if tag_name == '.': if tag_name == '.':
raw = self.view.context_list[0] raw = self.context.top()
else: else:
return '' return ''
...@@ -197,7 +211,8 @@ class Template(object): ...@@ -197,7 +211,8 @@ class Template(object):
@modifiers.set('>') @modifiers.set('>')
def _render_partial(self, template_name): def _render_partial(self, template_name):
markup = self.view.load_template(template_name) markup = self.view.load_template(template_name)
template = Template(markup, self.view) template = Template(markup, self.context)
template.view = self.view
return template.render() return template.render()
@modifiers.set('=') @modifiers.set('=')
...@@ -218,14 +233,14 @@ class Template(object): ...@@ -218,14 +233,14 @@ class Template(object):
Render a tag without escaping it. Render a tag without escaping it.
""" """
return literal(self.view.get(tag_name, '')) return literal(self.context.get(tag_name, ''))
def render(self, encoding=None): def render(self, encoding=None):
""" """
Return the template rendered using the current view context. Return the template rendered using the current view context.
""" """
template = self._render_sections(self.template, self.view) template = self._render_sections(self.template)
result = self._render_tags(template) result = self._render_tags(template)
if encoding is not None: if encoding is not None:
......
...@@ -8,32 +8,11 @@ This module provides a View class. ...@@ -8,32 +8,11 @@ This module provides a View class.
import re import re
from types import UnboundMethodType from types import UnboundMethodType
from .context import Context
from .loader import Loader from .loader import Loader
from .template import Template from .template import Template
def get_or_attr(context_list, name, default=None):
"""
Find and return an attribute from the given context.
"""
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): class View(object):
template_name = None template_name = None
...@@ -56,22 +35,13 @@ class View(object): ...@@ -56,22 +35,13 @@ class View(object):
if template is not None: if template is not None:
self.template = template self.template = template
context = context or {} _context = Context(self)
context.update(**kwargs) if context:
_context.push(context)
self.context_list = [context] if kwargs:
_context.push(kwargs)
def get(self, attr, default=None): self.context = _context
"""
Return the value for the given attribute.
"""
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 load_template(self, template_name): def load_template(self, template_name):
if self._load_template is None: if self._load_template is None:
...@@ -118,13 +88,6 @@ class View(object): ...@@ -118,13 +88,6 @@ class View(object):
return re.sub('[A-Z]', repl, template_name)[1:] 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): def render(self, encoding=None):
""" """
Return the view rendered using the current context. Return the view rendered using the current context.
...@@ -133,25 +96,8 @@ class View(object): ...@@ -133,25 +96,8 @@ class View(object):
template = Template(self.get_template(), self) template = Template(self.get_template(), self)
return template.render(encoding=encoding) return template.render(encoding=encoding)
def __contains__(self, needle): def get(self, key, default=None):
return needle in self.context or hasattr(self, needle) return self.context.get(key, default)
def __getitem__(self, attr):
val = self.get(attr, None)
# 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:
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): def __str__(self):
return self.render() return self.render()
# coding: utf-8
"""
Unit tests of context.py.
"""
import unittest
from pystache.context import _NOT_FOUND
from pystache.context import _get_item
from pystache.context import Context
class TestCase(unittest.TestCase):
"""A TestCase class with support for assertIs()."""
# unittest.assertIs() is not available until Python 2.7:
# http://docs.python.org/library/unittest.html#unittest.TestCase.assertIsNone
def assertIs(self, first, second):
self.assertTrue(first is second, msg="%s is not %s" % (repr(first), repr(second)))
class SimpleObject(object):
"""A sample class that does not define __getitem__()."""
def __init__(self):
self.foo = "bar"
def foo_callable(self):
return "called..."
class MappingObject(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 GetItemTestCase(TestCase):
"""Test context._get_item()."""
def assertNotFound(self, obj, key):
"""
Assert that a call to _get_item() returns _NOT_FOUND.
"""
self.assertIs(_get_item(obj, key), _NOT_FOUND)
### Case: obj is a dictionary.
def test_dictionary__key_present(self):
"""
Test getting a key from a dictionary.
"""
obj = {"foo": "bar"}
self.assertEquals(_get_item(obj, "foo"), "bar")
def test_dictionary__key_missing(self):
"""
Test getting a missing key from a dictionary.
"""
obj = {}
self.assertNotFound(obj, "missing")
def test_dictionary__attributes_not_checked(self):
"""
Test that dictionary attributes are not checked.
"""
obj = {}
attr_name = "keys"
self.assertEquals(getattr(obj, attr_name)(), [])
self.assertNotFound(obj, attr_name)
### Case: obj does not implement __getitem__().
def test_object__attribute_present(self):
"""
Test getting an attribute from an object.
"""
obj = SimpleObject()
self.assertEquals(_get_item(obj, "foo"), "bar")
def test_object__attribute_missing(self):
"""
Test getting a missing attribute from an object.
"""
obj = SimpleObject()
self.assertNotFound(obj, "missing")
def test_object__attribute_is_callable(self):
"""
Test getting a callable attribute from an object.
"""
obj = SimpleObject()
self.assertEquals(_get_item(obj, "foo_callable"), "called...")
### Case: obj implements __getitem__() (i.e. a "mapping object").
def test_mapping__key_present(self):
"""
Test getting a key from a mapping object.
"""
obj = MappingObject()
self.assertEquals(_get_item(obj, "foo"), "bar")
def test_mapping__key_missing(self):
"""
Test getting a missing key from a mapping object.
"""
obj = MappingObject()
self.assertNotFound(obj, "missing")
def test_mapping__get_attribute(self):
"""
Test getting an attribute from a mapping object.
"""
obj = MappingObject()
key = "fuzz"
self.assertEquals(getattr(obj, key), "buzz")
# As desired, __getitem__()'s presence causes obj.fuzz not to be checked.
self.assertNotFound(obj, key)
def test_mapping_object__not_implementing_contains(self):
"""
Test querying a mapping object that doesn't define __contains__().
"""
class Sample(object):
def __getitem__(self, key):
return "bar"
obj = Sample()
self.assertRaises(AttributeError, _get_item, obj, "foo")
class ContextTestCase(TestCase):
"""
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_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")
...@@ -25,6 +25,15 @@ class ViewTestCase(unittest.TestCase): ...@@ -25,6 +25,15 @@ class ViewTestCase(unittest.TestCase):
view = TestView() view = TestView()
self.assertEquals(view.template, "foo") self.assertEquals(view.template, "foo")
def test_init__kwargs_does_not_modify_context(self):
"""
Test that passing **kwargs does not modify the passed context.
"""
context = {"foo": "bar"}
view = View(context=context, fuzz="buzz")
self.assertEquals(context, {"foo": "bar"})
def test_basic(self): def test_basic(self):
view = Simple("Hi {{thing}}!", { 'thing': 'world' }) view = Simple("Hi {{thing}}!", { 'thing': 'world' })
self.assertEquals(view.render(), "Hi world!") self.assertEquals(view.render(), "Hi world!")
...@@ -164,12 +173,6 @@ class ViewTestCase(unittest.TestCase): ...@@ -164,12 +173,6 @@ class ViewTestCase(unittest.TestCase):
self.assertEquals(view.render(), 'derp') 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): def test_inverted_lists(self):
view = InvertedLists() view = InvertedLists()
self.assertEquals(view.render(), """one, two, three, empty list""") self.assertEquals(view.render(), """one, two, three, empty list""")
......
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