Commit ee579e21 by Chris Jerdonek

Addresses issue #81: not to call methods on instances of built-in types.

parent 4b8a6952
......@@ -5,11 +5,12 @@ Defines a Context class to represent mustache(5)'s notion of context.
"""
class NotFound(object): pass
# We use this private global variable as a return value to represent a key
# not being found on lookup. This lets us distinguish between the case
# of a key's value being None with the case of a key not being found --
# without having to rely on exceptions (e.g. KeyError) for flow control.
_NOT_FOUND = object()
_NOT_FOUND = NotFound()
# TODO: share code with template.check_callable().
......@@ -17,40 +18,35 @@ def _is_callable(obj):
return hasattr(obj, '__call__')
def _get_item(obj, key):
def _get_value(item, key):
"""
Return a key's value, or _NOT_FOUND if the key does not exist.
Retrieve a key's value from an item.
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.
Returns _NOT_FOUND if the key does not exist.
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.
The Context.get() docstring documents this function's intended behavior.
"""
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:
if isinstance(item, dict):
# Then we consider the argument a "hash" for the purposes of the spec.
#
# "For objects that don’t define __contains__(), the membership test
# first tries iteration via __iter__(), then the old sequence
# iteration protocol via __getitem__()...."
# We do a membership test to avoid using exceptions for flow control
# (e.g. catching KeyError).
if key in item:
return item[key]
elif type(item).__module__ != '__builtin__':
# Then we consider the argument an "object" for the purposes of
# the spec.
#
# (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
# The elif test above lets us avoid treating instances of built-in
# types like integers and strings as objects (cf. issue #81).
# Instances of user-defined classes on the other hand, for example,
# are considered objects by the test above.
if hasattr(item, key):
attr = getattr(item, key)
if _is_callable(attr):
return attr()
return attr
return _NOT_FOUND
......@@ -61,18 +57,18 @@ 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).
when rendering Mustache templates in accordance with mustache(5)
and the Mustache spec.
*Caution*:
Instances encapsulate a private stack of hashes, objects, and built-in
type instances. Querying the stack for the value of a key queries
the items in the stack in order from last-added objects to first
(last in, first out).
This class currently does not support recursive nesting in that
items in the stack cannot themselves be Context instances.
Caution: this class does not currently support recursive nesting in
that items in the stack cannot themselves be Context instances.
See the docstrings of the methods of this class for more information.
See the docstrings of the methods of this class for more details.
"""
......@@ -87,27 +83,8 @@ class Context(object):
stack in order so that, in particular, items at the end of
the argument list are queried first when querying the stack.
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.
*Caution*:
Items should not themselves be Context instances, as recursive
nesting does not behave as one might expect.
Caution: items should not themselves be Context instances, as
recursive nesting does not behave as one might expect.
"""
self._stack = list(items)
......@@ -128,11 +105,11 @@ class Context(object):
@staticmethod
def create(*context, **kwargs):
"""
Build a Context instance from a sequence of "mapping-like" objects.
Build a Context instance from a sequence of context-like items.
This factory-style method is more general than the Context class's
constructor in that Context instances can themselves appear in the
argument list. This is not true of the constructor.
constructor in that, unlike the constructor, the argument list
can itself contain Context instances.
Here is an example illustrating various aspects of this method:
......@@ -185,56 +162,71 @@ class Context(object):
"""
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.
This method queries items in the stack in order from last-added
objects to first (last in, first out). The value returned is
the value of the key in the first item that contains the key.
If the key is not found in any item in the stack, then the default
value is returned. The default value defaults to None.
When speaking about returning values from a context, the Mustache
spec distinguishes between two types of context stack elements:
hashes and objects.
In accordance with the spec, this method queries items in the
stack for a key in the following way. For the purposes of querying,
each item is classified into one of the following three mutually
exclusive categories: a hash, an object, or neither:
(1) Hash: if the item's type is a subclass of dict, then the item
is considered a hash (in the terminology of the spec), and
the key's value is the dictionary value of the key. If the
dictionary doesn't contain the key, the key is not found.
(2) Object: if the item isn't a hash and isn't an instance of a
built-in type, then the item is considered an object (again
using the language of the spec). In this case, the method
looks for an attribute with the same name as the key. If an
attribute with that name exists, the value of the attribute is
returned. If the attribute is callable, however (i.e. if the
attribute is a method), then the attribute is called with no
arguments and instead that value returned. If there is no
attribute with the same name as the key, then the key is
considered not found.
(3) Neither: if the item is neither a hash nor an object, then
the key is considered not found.
*Caution*:
Callables resulting from a call to __getitem__ (as in (1)
above) are handled differently from callables that are merely
attributes (as in (2) above).
The former are returned as-is, while the latter are first called
and that value returned. Here is an example:
>>> def greet():
... return "Hi Bob!"
>>>
>>> class Greeter(object):
... greet = None
>>>
>>> obj = Greeter()
>>> obj.greet = greet
>>> dct = {'greet': greet}
>>>
>>> obj.greet is dct['greet']
True
>>> Context(obj).get('greet')
'Hi Bob!'
>>> Context(dct).get('greet') #doctest: +ELLIPSIS
<function greet at 0x...>
TODO: explain the rationale for this difference in treatment.
Callables are handled differently depending on whether they are
dictionary values, as in (1) above, or attributes, as in (2).
The former are returned as-is, while the latter are first
called and that value returned.
Here is an example to illustrate:
>>> def greet():
... return "Hi Bob!"
>>>
>>> class Greeter(object):
... greet = None
>>>
>>> dct = {'greet': greet}
>>> obj = Greeter()
>>> obj.greet = greet
>>>
>>> dct['greet'] is obj.greet
True
>>> Context(dct).get('greet') #doctest: +ELLIPSIS
<function greet at 0x...>
>>> Context(obj).get('greet')
'Hi Bob!'
TODO: explain the rationale for this difference in treatment.
"""
for obj in reversed(self._stack):
val = _get_item(obj, key)
val = _get_value(obj, key)
if val is _NOT_FOUND:
continue
# Otherwise, the key was found.
......
......@@ -5,23 +5,14 @@ Unit tests of context.py.
"""
from datetime import datetime
import unittest
from pystache.context import _NOT_FOUND
from pystache.context import _get_item
from pystache.context import _get_value
from pystache.context import Context
class AssertIsMixin:
"""A mixin for adding assertIs() to a unittest.TestCase."""
# 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__()."""
......@@ -33,7 +24,7 @@ class SimpleObject(object):
return "called..."
class MappingObject(object):
class DictLike(object):
"""A sample class that implements __getitem__() and __contains__()."""
......@@ -48,26 +39,36 @@ class MappingObject(object):
return self._dict[key]
class GetItemTestCase(unittest.TestCase, AssertIsMixin):
class AssertIsMixin:
"""A mixin for adding assertIs() to a unittest.TestCase."""
# 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)))
"""Test context._get_item()."""
class GetValueTests(unittest.TestCase, AssertIsMixin):
def assertNotFound(self, obj, key):
"""Test context._get_value()."""
def assertNotFound(self, item, key):
"""
Assert that a call to _get_item() returns _NOT_FOUND.
Assert that a call to _get_value() returns _NOT_FOUND.
"""
self.assertIs(_get_item(obj, key), _NOT_FOUND)
self.assertIs(_get_value(item, key), _NOT_FOUND)
### Case: obj is a dictionary.
### Case: the item 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")
item = {"foo": "bar"}
self.assertEquals(_get_value(item, "foo"), "bar")
def test_dictionary__callable_not_called(self):
"""
......@@ -77,95 +78,132 @@ class GetItemTestCase(unittest.TestCase, AssertIsMixin):
def foo_callable(self):
return "bar"
obj = {"foo": foo_callable}
self.assertNotEquals(_get_item(obj, "foo"), "bar")
self.assertTrue(_get_item(obj, "foo") is foo_callable)
item = {"foo": foo_callable}
self.assertNotEquals(_get_value(item, "foo"), "bar")
self.assertTrue(_get_value(item, "foo") is foo_callable)
def test_dictionary__key_missing(self):
"""
Test getting a missing key from a dictionary.
"""
obj = {}
self.assertNotFound(obj, "missing")
item = {}
self.assertNotFound(item, "missing")
def test_dictionary__attributes_not_checked(self):
"""
Test that dictionary attributes are not checked.
"""
obj = {}
item = {}
attr_name = "keys"
self.assertEquals(getattr(obj, attr_name)(), [])
self.assertNotFound(obj, attr_name)
self.assertEquals(getattr(item, attr_name)(), [])
self.assertNotFound(item, attr_name)
def test_dictionary__dict_subclass(self):
"""
Test that subclasses of dict are treated as dictionaries.
"""
class DictSubclass(dict): pass
### Case: obj does not implement __getitem__().
item = DictSubclass()
item["foo"] = "bar"
self.assertEquals(_get_value(item, "foo"), "bar")
### Case: the item is an object.
def test_object__attribute_present(self):
"""
Test getting an attribute from an object.
"""
obj = SimpleObject()
self.assertEquals(_get_item(obj, "foo"), "bar")
item = SimpleObject()
self.assertEquals(_get_value(item, "foo"), "bar")
def test_object__attribute_missing(self):
"""
Test getting a missing attribute from an object.
"""
obj = SimpleObject()
self.assertNotFound(obj, "missing")
item = SimpleObject()
self.assertNotFound(item, "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...")
item = SimpleObject()
self.assertEquals(_get_value(item, "foo_callable"), "called...")
def test_object__non_built_in_type(self):
"""
Test getting an attribute from an instance of a type that isn't built-in.
### Case: obj implements __getitem__() (i.e. a "mapping object").
"""
item = datetime(2012, 1, 2)
self.assertEquals(_get_value(item, "day"), 2)
def test_mapping__key_present(self):
def test_object__dict_like(self):
"""
Test getting a key from a mapping object.
Test getting a key from a dict-like object (an object that implements '__getitem__').
"""
obj = MappingObject()
self.assertEquals(_get_item(obj, "foo"), "bar")
item = DictLike()
self.assertEquals(item["foo"], "bar")
self.assertNotFound(item, "foo")
### Case: the item is an instance of a built-in type.
def test_mapping__key_missing(self):
def test_built_in_type__integer(self):
"""
Test getting a missing key from a mapping object.
Test getting from an integer.
"""
obj = MappingObject()
self.assertNotFound(obj, "missing")
class MyInt(int): pass
item1 = MyInt(10)
item2 = 10
self.assertEquals(item1.real, 10)
self.assertEquals(item2.real, 10)
self.assertEquals(_get_value(item1, 'real'), 10)
self.assertNotFound(item2, 'real')
def test_mapping__get_attribute(self):
def test_built_in_type__string(self):
"""
Test getting an attribute from a mapping object.
Test getting from a string.
"""
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)
class MyStr(str): pass
def test_mapping_object__not_implementing_contains(self):
item1 = MyStr('abc')
item2 = 'abc'
self.assertEquals(item1.upper(), 'ABC')
self.assertEquals(item2.upper(), 'ABC')
self.assertEquals(_get_value(item1, 'upper'), 'ABC')
self.assertNotFound(item2, 'upper')
def test_built_in_type__list(self):
"""
Test querying a mapping object that doesn't define __contains__().
Test getting from a list.
"""
class Sample(object):
class MyList(list): pass
item1 = MyList([1, 2, 3])
item2 = [1, 2, 3]
def __getitem__(self, key):
return "bar"
self.assertEquals(item1.pop(), 3)
self.assertEquals(item2.pop(), 3)
obj = Sample()
self.assertRaises(AttributeError, _get_item, obj, "foo")
self.assertEquals(_get_value(item1, 'pop'), 2)
self.assertNotFound(item2, 'pop')
class ContextTests(unittest.TestCase, AssertIsMixin):
......
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