Commit b6018af6 by Chris Jerdonek

Merge 'issue_58' into development: closing issue #58 (RenderEngine class)

parents 73f3d72f 331f149e
# coding: utf-8
"""
Defines a class responsible for rendering logic.
"""
import collections
import re
try:
# The collections.Callable class is not available until Python 2.6.
import collections.Callable
def check_callable(it):
return isinstance(it, collections.Callable)
except ImportError:
def check_callable(it):
return hasattr(it, '__call__')
class Modifiers(dict):
"""Dictionary with a decorator for assigning functions to keys."""
def set(self, key):
"""
Return a decorator that assigns the given function to the given key.
>>> modifiers = {}
>>> @modifiers.set('P')
... def render_tongue(self, tag_name=None, context=None):
... return ":P %s" % tag_name
>>> modifiers
{'P': <function render_tongue at 0x...>}
"""
def decorate(func):
self[key] = func
return func
return decorate
class RenderEngine(object):
"""
Provides a render() method.
This class is meant only for internal use by the Template class.
"""
tag_re = None
otag = '{{'
ctag = '}}'
modifiers = Modifiers()
def __init__(self, load_template=None, literal=None, escape=None):
"""
Arguments:
escape: a function that takes a unicode or str string,
converts it to unicode, and escapes and returns it.
literal: a function that converts a unicode or str string
to unicode without escaping, and returns it.
"""
self.escape = escape
self.literal = literal
self.load_template = load_template
def render(self, template, context):
"""
Arguments:
template: a unicode template string.
context: a Context instance.
"""
self.context = context
self._compile_regexps()
return self._render(template)
def _compile_regexps(self):
"""
Compile and set the regular expression attributes.
This method uses the current values for the otag and ctag attributes.
"""
tags = {
'otag': re.escape(self.otag),
'ctag': re.escape(self.ctag)
}
# The section contents include white space to comply with the spec's
# requirement that sections not alter surrounding whitespace.
section = r"%(otag)s([#|^])([^\}]*)%(ctag)s(.+?)%(otag)s/\2%(ctag)s" % tags
self.section_re = re.compile(section, re.M|re.S)
tag = r"%(otag)s(#|=|&|!|>|\{)?(.+?)\1?%(ctag)s+" % tags
self.tag_re = re.compile(tag)
def _render_tags(self, template):
output = []
while True:
parts = self.tag_re.split(template, maxsplit=1)
output.append(parts[0])
if len(parts) < 2:
# Then there was no match.
break
start, tag_type, tag_name, template = parts
tag_name = tag_name.strip()
func = self.modifiers[tag_type]
tag_value = func(self, tag_name)
# Appending the tag value to the output prevents treating the
# value as a template string (bug: issue #44).
output.append(tag_value)
output = "".join(output)
return output
def _render_dictionary(self, template, context):
self.context.push(context)
out = self._render(template)
self.context.pop()
return out
def _render_list(self, template, listing):
insides = []
for item in listing:
insides.append(self._render_dictionary(template, item))
return ''.join(insides)
@modifiers.set(None)
def _render_tag(self, tag_name):
"""
Return the value of a variable as an escaped unicode string.
"""
raw = self.context.get(tag_name, '')
# For methods with no return value
#
# 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 raw and raw != 0:
if tag_name == '.':
raw = self.context.top()
else:
return ''
# If we don't first convert to a string type, the call to self._unicode_and_escape()
# will yield an error like the following:
#
# TypeError: coercing to Unicode: need string or buffer, ... found
#
if not isinstance(raw, basestring):
raw = str(raw)
return self.escape(raw)
@modifiers.set('!')
def _render_comment(self, tag_name):
return ''
@modifiers.set('>')
def _render_partial(self, template_name):
markup = self.load_template(template_name)
return self._render(markup)
@modifiers.set('=')
def _change_delimiter(self, tag_name):
"""
Change the current delimiter.
"""
self.otag, self.ctag = tag_name.split(' ')
self._compile_regexps()
return ''
@modifiers.set('{')
@modifiers.set('&')
def render_unescaped(self, tag_name):
"""
Render a tag without escaping it.
"""
return self.literal(self.context.get(tag_name, ''))
def _render(self, template):
"""
Arguments:
template: a unicode template string.
"""
output = []
while True:
parts = self.section_re.split(template, maxsplit=1)
start = self._render_tags(parts[0])
output.append(start)
if len(parts) < 2:
# Then there was no match.
break
section_type, section_key, section_contents, template = parts[1:]
section_key = section_key.strip()
section_value = self.context.get(section_key, None)
rendered = ''
# Callable
if section_value and check_callable(section_value):
rendered = section_value(section_contents)
# Dictionary
elif section_value and hasattr(section_value, 'keys') and hasattr(section_value, '__getitem__'):
if section_type != '^':
rendered = self._render_dictionary(section_contents, section_value)
# Lists
elif section_value and hasattr(section_value, '__iter__'):
if section_type != '^':
rendered = self._render_list(section_contents, section_value)
# Other objects
elif section_value and isinstance(section_value, object):
if section_type != '^':
rendered = self._render_dictionary(section_contents, section_value)
# Falsey and Negated or Truthy and Not Negated
elif (not section_value and section_type == '^') or (section_value and section_type != '^'):
rendered = self._render_dictionary(section_contents, section_value)
# Render template prior to section too
output.append(rendered)
output = "".join(output)
return output
...@@ -6,12 +6,11 @@ This module provides a Template class. ...@@ -6,12 +6,11 @@ This module provides a Template class.
""" """
import cgi import cgi
import collections
import re
import sys import sys
from .context import Context from .context import Context
from .loader import Loader from .loader import Loader
from .renderengine import RenderEngine
markupsafe = None markupsafe = None
...@@ -21,46 +20,8 @@ except ImportError: ...@@ -21,46 +20,8 @@ except ImportError:
pass pass
try:
# The collections.Callable class is not available until Python 2.6.
import collections.Callable
def check_callable(it):
return isinstance(it, collections.Callable)
except ImportError:
def check_callable(it):
return hasattr(it, '__call__')
class Modifiers(dict):
"""Dictionary with a decorator for assigning functions to keys."""
def set(self, key):
"""
Return a decorator that assigns the given function to the given key.
>>> modifiers = {}
>>> @modifiers.set('P')
... def render_tongue(self, tag_name=None, context=None):
... return ":P %s" % tag_name
>>> modifiers
{'P': <function render_tongue at 0x...>}
"""
def decorate(func):
self[key] = func
return func
return decorate
class Template(object): class Template(object):
tag_re = None
otag = '{{'
ctag = '}}'
modifiers = Modifiers()
def __init__(self, template=None, load_template=None, output_encoding=None, escape=None, def __init__(self, template=None, load_template=None, output_encoding=None, escape=None,
default_encoding=None, decode_errors='strict'): default_encoding=None, decode_errors='strict'):
""" """
...@@ -125,8 +86,6 @@ class Template(object): ...@@ -125,8 +86,6 @@ class Template(object):
self.output_encoding = output_encoding self.output_encoding = output_encoding
self.template = template self.template = template
self._compile_regexps()
def _unicode_and_escape(self, s): def _unicode_and_escape(self, s):
if not isinstance(s, unicode): if not isinstance(s, unicode):
s = self.unicode(s) s = self.unicode(s)
...@@ -159,7 +118,7 @@ class Template(object): ...@@ -159,7 +118,7 @@ class Template(object):
""" """
return self._literal(self.unicode(s)) return self._literal(self.unicode(s))
def _initialize_context(self, context, **kwargs): def _make_context(self, context, **kwargs):
""" """
Initialize the context attribute. Initialize the context attribute.
...@@ -175,185 +134,17 @@ class Template(object): ...@@ -175,185 +134,17 @@ class Template(object):
if kwargs: if kwargs:
context.push(kwargs) context.push(kwargs)
self.context = context return context
def _compile_regexps(self):
"""
Compile and set the regular expression attributes.
This method uses the current values for the otag and ctag attributes.
"""
tags = {
'otag': re.escape(self.otag),
'ctag': re.escape(self.ctag)
}
# The section contents include white space to comply with the spec's
# requirement that sections not alter surrounding whitespace.
section = r"%(otag)s([#|^])([^\}]*)%(ctag)s(.+?)%(otag)s/\2%(ctag)s" % tags
self.section_re = re.compile(section, re.M|re.S)
tag = r"%(otag)s(#|=|&|!|>|\{)?(.+?)\1?%(ctag)s+" % tags
self.tag_re = re.compile(tag)
def _render(self, template):
"""
Arguments:
template: a unicode template string.
"""
output = []
while True:
parts = self.section_re.split(template, maxsplit=1)
start = self._render_tags(parts[0])
output.append(start)
if len(parts) < 2:
# Then there was no match.
break
section_type, section_key, section_contents, template = parts[1:]
section_key = section_key.strip()
section_value = self.context.get(section_key, None)
rendered = ''
# Callable
if section_value and check_callable(section_value):
rendered = section_value(section_contents)
# Dictionary
elif section_value and hasattr(section_value, 'keys') and hasattr(section_value, '__getitem__'):
if section_type != '^':
rendered = self._render_dictionary(section_contents, section_value)
# Lists
elif section_value and hasattr(section_value, '__iter__'):
if section_type != '^':
rendered = self._render_list(section_contents, section_value)
# Other objects
elif section_value and isinstance(section_value, object):
if section_type != '^':
rendered = self._render_dictionary(section_contents, section_value)
# Falsey and Negated or Truthy and Not Negated
elif (not section_value and section_type == '^') or (section_value and section_type != '^'):
rendered = self._render_dictionary(section_contents, section_value)
# Render template prior to section too
output.append(rendered)
output = "".join(output)
return output
def _render_tags(self, template):
output = []
while True:
parts = self.tag_re.split(template, maxsplit=1)
output.append(parts[0])
if len(parts) < 2:
# Then there was no match.
break
start, tag_type, tag_name, template = parts
tag_name = tag_name.strip()
func = self.modifiers[tag_type]
tag_value = func(self, tag_name)
# Appending the tag value to the output prevents treating the
# value as a template string (bug: issue #44).
output.append(tag_value)
output = "".join(output)
return output
def _render_dictionary(self, template, context):
self.context.push(context)
template = Template(template, load_template=self.load_template, escape=self.escape,
default_encoding=self.default_encoding, decode_errors=self.decode_errors)
out = template.render(self.context)
self.context.pop()
return out
def _render_list(self, template, listing):
insides = []
for item in listing:
insides.append(self._render_dictionary(template, item))
return ''.join(insides)
@modifiers.set(None)
def _render_tag(self, tag_name):
"""
Return the value of a variable as an escaped unicode string.
"""
raw = self.context.get(tag_name, '')
# For methods with no return value
#
# 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 raw and raw != 0:
if tag_name == '.':
raw = self.context.top()
else:
return ''
# If we don't first convert to a string type, the call to self._unicode_and_escape()
# will yield an error like the following:
#
# TypeError: coercing to Unicode: need string or buffer, ... found
#
if not isinstance(raw, basestring):
raw = str(raw)
return self._unicode_and_escape(raw)
@modifiers.set('!')
def _render_comment(self, tag_name):
return ''
@modifiers.set('>')
def _render_partial(self, template_name):
markup = self.load_template(template_name)
template = Template(markup, load_template=self.load_template, escape=self.escape,
default_encoding=self.default_encoding, decode_errors=self.decode_errors)
return template.render(self.context)
@modifiers.set('=')
def _change_delimiter(self, tag_name):
"""
Change the current delimiter.
"""
self.otag, self.ctag = tag_name.split(' ')
self._compile_regexps()
return ''
@modifiers.set('{') def _make_render_engine(self):
@modifiers.set('&')
def render_unescaped(self, tag_name):
""" """
Render a tag without escaping it. Return a RenderEngine instance for rendering.
""" """
return self.literal(self.context.get(tag_name, '')) engine = RenderEngine(load_template=self.load_template,
literal=self.literal,
escape=self._unicode_and_escape)
return engine
def render(self, context=None, **kwargs): def render(self, context=None, **kwargs):
""" """
...@@ -375,13 +166,14 @@ class Template(object): ...@@ -375,13 +166,14 @@ class Template(object):
These values take precedence over the context on any key conflicts. These values take precedence over the context on any key conflicts.
""" """
self._initialize_context(context, **kwargs) engine = self._make_render_engine()
context = self._make_context(context, **kwargs)
template = self.template template = self.template
if not isinstance(template, unicode): if not isinstance(template, unicode):
template = self.unicode(template) template = self.unicode(template)
result = self._render(template) result = engine.render(template, context)
if self.output_encoding is not None: if self.output_encoding is not None:
result = result.encode(self.output_encoding) result = result.encode(self.output_encoding)
......
# coding: utf-8
"""
Unit tests of renderengine.py.
"""
import cgi
import unittest
from pystache.context import Context
from pystache.renderengine import RenderEngine
class RenderEngineTestCase(unittest.TestCase):
"""Test the RenderEngine class."""
def _engine(self):
"""
Create and return a default RenderEngine for testing.
"""
load_template = None
to_unicode = unicode
escape = lambda s: cgi.escape(to_unicode(s))
literal = to_unicode
engine = RenderEngine(literal=literal, escape=escape, load_template=None)
return engine
def _assert_render(self, expected, template, *context, **kwargs):
partials = kwargs.get('partials')
engine = kwargs.get('engine', self._engine())
if partials is not None:
engine.load_template = lambda key: partials[key]
context = Context(*context)
actual = engine.render(template, context)
self.assertEquals(actual, expected)
def test_init(self):
"""
Test that __init__() stores all of the arguments correctly.
"""
# In real-life, these arguments would be functions
engine = RenderEngine(load_template="load_template", literal="literal", escape="escape")
self.assertEquals(engine.escape, "escape")
self.assertEquals(engine.literal, "literal")
self.assertEquals(engine.load_template, "load_template")
def test_render(self):
self._assert_render('Hi Mom', 'Hi {{person}}', {'person': 'Mom'})
def test_render__load_template(self):
"""
Test that render() uses the load_template attribute.
"""
engine = self._engine()
partials = {'partial': "{{person}}"}
engine.load_template = lambda key: partials[key]
self._assert_render('Hi Mom', 'Hi {{>partial}}', {'person': 'Mom'}, engine=engine)
def test_render__literal(self):
"""
Test that render() uses the literal attribute.
"""
engine = self._engine()
engine.literal = lambda s: s.upper()
self._assert_render('bar BAR', '{{foo}} {{{foo}}}', {'foo': 'bar'}, engine=engine)
def test_render__escape(self):
"""
Test that render() uses the escape attribute.
"""
engine = self._engine()
engine.escape = lambda s: "**" + s
self._assert_render('**bar bar', '{{foo}} {{{foo}}}', {'foo': 'bar'}, engine=engine)
def test_render_with_partial(self):
partials = {'partial': "{{person}}"}
self._assert_render('Hi Mom', 'Hi {{>partial}}', {'person': 'Mom'}, partials=partials)
def test_render__section_context_values(self):
"""
Test that escape and literal work on context values in sections.
"""
engine = self._engine()
engine.escape = lambda s: "**" + s
engine.literal = lambda s: s.upper()
template = '{{#test}}{{foo}} {{{foo}}}{{/test}}'
context = {'test': {'foo': 'bar'}}
self._assert_render('**bar BAR', template, context, engine=engine)
def test_render__partial_context_values(self):
"""
Test that escape and literal work on context values in partials.
"""
engine = self._engine()
engine.escape = lambda s: "**" + s
engine.literal = lambda s: s.upper()
partials = {'partial': '{{foo}} {{{foo}}}'}
self._assert_render('**bar BAR', '{{>partial}}', {'foo': 'bar'}, engine=engine, partials=partials)
def test_render__list_referencing_outer_context(self):
"""
Check that list items can access the parent context.
For sections whose value is a list, check that items in the list
have access to the values inherited from the parent context
when rendering.
"""
context = {
"list": [{"name": "Al"}, {"name": "Bo"}],
"greeting": "Hi",
}
template = "{{#list}}{{name}}: {{greeting}}; {{/list}}"
self._assert_render("Al: Hi; Bo: Hi; ", template, context)
def test_render__tag_in_value(self):
"""
Context values should not be treated as templates (issue #44).
"""
template = '{{test}}'
context = {'test': '{{hello}}'}
self._assert_render('{{hello}}', template, context)
def test_render__section_in_value(self):
"""
Context values should not be treated as templates (issue #44).
"""
template = '{{test}}'
context = {'test': '{{#hello}}'}
self._assert_render('{{#hello}}', template, context)
def test_render__section__lambda(self):
template = '{{#test}}Mom{{/test}}'
context = {'test': (lambda text: 'Hi %s' % text)}
self._assert_render('Hi Mom', template, context)
def test_render__section__lambda__tag_in_output(self):
"""
Check that callable output isn't treated as a template string (issue #46).
"""
template = '{{#test}}Mom{{/test}}'
context = {'test': (lambda text: '{{hi}} %s' % text)}
self._assert_render('{{hi}} Mom', template, context)
...@@ -38,6 +38,19 @@ class TemplateTestCase(unittest.TestCase): ...@@ -38,6 +38,19 @@ class TemplateTestCase(unittest.TestCase):
""" """
template.markupsafe = self.original_markupsafe template.markupsafe = self.original_markupsafe
def test__was_markupsafe_imported(self):
"""
Test that our helper function works.
"""
markupsafe = None
try:
import markupsafe
except:
pass
self.assertEquals(bool(markupsafe), self._was_markupsafe_imported())
def test_init__escape__default_without_markupsafe(self): def test_init__escape__default_without_markupsafe(self):
template = Template() template = Template()
self.assertEquals(template.escape(">'"), "&gt;'") self.assertEquals(template.escape(">'"), "&gt;'")
...@@ -207,138 +220,60 @@ class TemplateTestCase(unittest.TestCase): ...@@ -207,138 +220,60 @@ class TemplateTestCase(unittest.TestCase):
self.assertTrue(isinstance(actual, str)) self.assertTrue(isinstance(actual, str))
self.assertEquals(actual, 'Poincaré') self.assertEquals(actual, 'Poincaré')
def test_render__tag_in_value(self): def test_render__nonascii_template(self):
"""
Context values should not be treated as templates (issue #44).
""" """
template = Template('{{test}}') Test passing a non-unicode template with non-ascii characters.
context = {'test': '{{hello}}'}
actual = template.render(context)
self.assertEquals(actual, '{{hello}}')
def test_render__section_in_value(self):
""" """
Context values should not be treated as templates (issue #44). template = Template("déf", output_encoding="utf-8")
""" # Check that decode_errors and default_encoding are both respected.
template = Template('{{test}}') template.decode_errors = 'ignore'
context = {'test': '{{#hello}}'} template.default_encoding = 'ascii'
actual = template.render(context) self.assertEquals(template.render(), "df")
self.assertEquals(actual, '{{#hello}}')
def test_render__section__lambda(self): template.default_encoding = 'utf_8'
template = Template('{{#test}}Mom{{/test}}') self.assertEquals(template.render(), "déf")
context = {'test': (lambda text: 'Hi %s' % text)}
actual = template.render(context)
self.assertEquals(actual, 'Hi Mom')
def test_render__section__lambda__tag_in_output(self): # By testing that Template.render() constructs the RenderEngine instance
# correctly, we no longer need to test the rendering code paths through
# the Template. We can test rendering paths through only the RenderEngine
# for the same amount of code coverage.
def test_make_render_engine__load_template(self):
""" """
Check that callable output isn't treated as a template string (issue #46). Test that _make_render_engine() passes the right load_template.
""" """
template = Template('{{#test}}Mom{{/test}}') template = Template()
context = {'test': (lambda text: '{{hi}} %s' % text)} template.load_template = "foo" # in real life, this would be a function.
actual = template.render(context)
self.assertEquals(actual, '{{hi}} Mom')
def test_render__html_escape(self):
context = {'test': '1 < 2'}
template = Template('{{test}}')
self.assertEquals(template.render(context), '1 &lt; 2')
def test_render__html_escape_disabled(self):
context = {'test': '1 < 2'}
template = Template('{{test}}')
self.assertEquals(template.render(context), '1 &lt; 2')
template.escape = lambda s: s
self.assertEquals(template.render(context), '1 < 2')
def test_render__html_escape_disabled_with_partial(self):
context = {'test': '1 < 2'}
load_template = lambda name: '{{test}}'
template = Template('{{>partial}}', load_template=load_template)
self.assertEquals(template.render(context), '1 &lt; 2')
template.escape = lambda s: s
self.assertEquals(template.render(context), '1 < 2')
def test_render__html_escape_disabled_with_non_false_value(self):
context = {'section': {'test': '1 < 2'}}
template = Template('{{#section}}{{test}}{{/section}}')
self.assertEquals(template.render(context), '1 &lt; 2')
template.escape = lambda s: s engine = template._make_render_engine()
self.assertEquals(template.render(context), '1 < 2') self.assertEquals(engine.load_template, "foo")
def test_render__list_referencing_outer_context(self): def test_make_render_engine__literal(self):
""" """
Check that list items can access the parent context. Test that _make_render_engine() passes the right literal.
For sections whose value is a list, check that items in the list
have access to the values inherited from the parent context
when rendering.
""" """
context = { template = Template()
"list": [{"name": "Al"}, {"name": "Bo"}], template.literal = "foo" # in real life, this would be a function.
"greeting": "Hi",
}
template = Template("{{#list}}{{name}}: {{greeting}}; {{/list}}")
self.assertEquals(template.render(context), "Al: Hi; Bo: Hi; ")
def test_render__encoding_in_context_value(self):
template = Template('{{test}}')
context = {'test': "déf"}
template.decode_errors = 'ignore'
template.default_encoding = 'ascii'
self.assertEquals(template.render(context), "df")
template.default_encoding = 'utf_8'
self.assertEquals(template.render(context), u"déf")
def test_render__encoding_in_section_context_value(self):
template = Template('{{#test}}{{foo}}{{/test}}')
context = {'test': {'foo': "déf"}}
template.decode_errors = 'ignore'
template.default_encoding = 'ascii'
self.assertEquals(template.render(context), "df")
template.default_encoding = 'utf_8'
self.assertEquals(template.render(context), u"déf")
def test_render__encoding_in_partial_context_value(self):
load_template = lambda x: "{{foo}}"
template = Template('{{>partial}}', load_template=load_template)
context = {'foo': "déf"}
template.decode_errors = 'ignore'
template.default_encoding = 'ascii'
self.assertEquals(template.render(context), "df")
template.default_encoding = 'utf_8' engine = template._make_render_engine()
self.assertEquals(template.render(context), u"déf") self.assertEquals(engine.literal, "foo")
def test_render__nonascii_template(self): def test_make_render_engine__escape(self):
""" """
Test passing a non-unicode template with non-ascii characters. Test that _make_render_engine() passes the right escape.
""" """
template = Template("déf", output_encoding="utf-8") template = Template()
template.unicode = lambda s: s.upper() # a test version.
template.escape = lambda s: "**" + s # a test version.
# Check that decode_errors and default_encoding are both respected. engine = template._make_render_engine()
template.decode_errors = 'ignore' escape = engine.escape
template.default_encoding = 'ascii'
self.assertEquals(template.render(), "df")
template.default_encoding = 'utf_8' self.assertEquals(escape(u"foo"), "**foo")
self.assertEquals(template.render(), "déf")
# Test that escape converts str strings to unicode first.
self.assertEquals(escape("foo"), "**FOO")
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