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.
"""
import cgi
import collections
import re
import sys
from .context import Context
from .loader import Loader
from .renderengine import RenderEngine
markupsafe = None
......@@ -21,46 +20,8 @@ except ImportError:
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):
tag_re = None
otag = '{{'
ctag = '}}'
modifiers = Modifiers()
def __init__(self, template=None, load_template=None, output_encoding=None, escape=None,
default_encoding=None, decode_errors='strict'):
"""
......@@ -125,8 +86,6 @@ class Template(object):
self.output_encoding = output_encoding
self.template = template
self._compile_regexps()
def _unicode_and_escape(self, s):
if not isinstance(s, unicode):
s = self.unicode(s)
......@@ -159,7 +118,7 @@ class Template(object):
"""
return self._literal(self.unicode(s))
def _initialize_context(self, context, **kwargs):
def _make_context(self, context, **kwargs):
"""
Initialize the context attribute.
......@@ -175,185 +134,17 @@ class Template(object):
if kwargs:
context.push(kwargs)
self.context = 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 ''
return context
@modifiers.set('{')
@modifiers.set('&')
def render_unescaped(self, tag_name):
def _make_render_engine(self):
"""
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):
"""
......@@ -375,13 +166,14 @@ class Template(object):
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
if not isinstance(template, unicode):
template = self.unicode(template)
result = self._render(template)
result = engine.render(template, context)
if self.output_encoding is not None:
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):
"""
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):
template = Template()
self.assertEquals(template.escape(">'"), "&gt;'")
......@@ -207,138 +220,60 @@ class TemplateTestCase(unittest.TestCase):
self.assertTrue(isinstance(actual, str))
self.assertEquals(actual, 'Poincaré')
def test_render__tag_in_value(self):
"""
Context values should not be treated as templates (issue #44).
def test_render__nonascii_template(self):
"""
template = Template('{{test}}')
context = {'test': '{{hello}}'}
actual = template.render(context)
self.assertEquals(actual, '{{hello}}')
Test passing a non-unicode template with non-ascii characters.
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")
"""
template = Template('{{test}}')
context = {'test': '{{#hello}}'}
actual = template.render(context)
self.assertEquals(actual, '{{#hello}}')
# Check that decode_errors and default_encoding are both respected.
template.decode_errors = 'ignore'
template.default_encoding = 'ascii'
self.assertEquals(template.render(), "df")
def test_render__section__lambda(self):
template = Template('{{#test}}Mom{{/test}}')
context = {'test': (lambda text: 'Hi %s' % text)}
actual = template.render(context)
self.assertEquals(actual, 'Hi Mom')
template.default_encoding = 'utf_8'
self.assertEquals(template.render(), "déf")
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}}')
context = {'test': (lambda text: '{{hi}} %s' % text)}
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 = Template()
template.load_template = "foo" # in real life, this would be a function.
template.escape = lambda s: s
self.assertEquals(template.render(context), '1 < 2')
engine = template._make_render_engine()
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.
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.
Test that _make_render_engine() passes the right literal.
"""
context = {
"list": [{"name": "Al"}, {"name": "Bo"}],
"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 = Template()
template.literal = "foo" # in real life, this would be a function.
template.default_encoding = 'utf_8'
self.assertEquals(template.render(context), u"déf")
engine = template._make_render_engine()
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.
template.decode_errors = 'ignore'
template.default_encoding = 'ascii'
self.assertEquals(template.render(), "df")
engine = template._make_render_engine()
escape = engine.escape
template.default_encoding = 'utf_8'
self.assertEquals(template.render(), "déf")
self.assertEquals(escape(u"foo"), "**foo")
# 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