diff options
Diffstat (limited to 'pystache')
-rw-r--r-- | pystache/__init__.py | 6 | ||||
-rw-r--r-- | pystache/common.py | 35 | ||||
-rw-r--r-- | pystache/context.py | 61 | ||||
-rw-r--r-- | pystache/defaults.py | 10 | ||||
-rw-r--r-- | pystache/init.py | 1 | ||||
-rw-r--r-- | pystache/loader.py | 28 | ||||
-rw-r--r-- | pystache/locator.py | 25 | ||||
-rw-r--r-- | pystache/parsed.py | 48 | ||||
-rw-r--r-- | pystache/parser.py | 333 | ||||
-rw-r--r-- | pystache/renderengine.py | 286 | ||||
-rw-r--r-- | pystache/renderer.py | 265 | ||||
-rw-r--r-- | pystache/specloader.py | 3 | ||||
-rw-r--r-- | pystache/template_spec.py | 30 | ||||
-rw-r--r-- | pystache/tests/common.py | 10 | ||||
-rw-r--r-- | pystache/tests/data/locator/template.txt | 1 | ||||
-rw-r--r-- | pystache/tests/doctesting.py | 6 | ||||
-rw-r--r-- | pystache/tests/main.py | 104 | ||||
-rw-r--r-- | pystache/tests/test___init__.py | 2 | ||||
-rw-r--r-- | pystache/tests/test_context.py | 65 | ||||
-rw-r--r-- | pystache/tests/test_defaults.py | 68 | ||||
-rw-r--r-- | pystache/tests/test_loader.py | 17 | ||||
-rw-r--r-- | pystache/tests/test_locator.py | 24 | ||||
-rw-r--r-- | pystache/tests/test_parser.py | 3 | ||||
-rw-r--r-- | pystache/tests/test_renderengine.py | 165 | ||||
-rw-r--r-- | pystache/tests/test_renderer.py | 182 | ||||
-rw-r--r-- | pystache/tests/test_specloader.py | 32 |
26 files changed, 1236 insertions, 574 deletions
diff --git a/pystache/__init__.py b/pystache/__init__.py index b07eb65..59dac98 100644 --- a/pystache/__init__.py +++ b/pystache/__init__.py @@ -6,8 +6,8 @@ TODO: add a docstring. # We keep all initialization code in a separate module. -from pystache.init import render, Renderer, TemplateSpec +from pystache.init import parse, render, Renderer, TemplateSpec -__all__ = ['render', 'Renderer', 'TemplateSpec'] +__all__ = ['parse', 'render', 'Renderer', 'TemplateSpec'] -__version__ = '0.5.2' # Also change in setup.py. +__version__ = '0.5.3-rc' # Also change in setup.py. diff --git a/pystache/common.py b/pystache/common.py index c1fd7a1..fb266dd 100644 --- a/pystache/common.py +++ b/pystache/common.py @@ -5,6 +5,33 @@ Exposes functionality needed throughout the project. """ +from sys import version_info + +def _get_string_types(): + # TODO: come up with a better solution for this. One of the issues here + # is that in Python 3 there is no common base class for unicode strings + # and byte strings, and 2to3 seems to convert all of "str", "unicode", + # and "basestring" to Python 3's "str". + if version_info < (3, ): + return basestring + # The latter evaluates to "bytes" in Python 3 -- even after conversion by 2to3. + return (unicode, type(u"a".encode('utf-8'))) + + +_STRING_TYPES = _get_string_types() + + +def is_string(obj): + """ + Return whether the given object is a byte string or unicode string. + + This function is provided for compatibility with both Python 2 and 3 + when using 2to3. + + """ + return isinstance(obj, _STRING_TYPES) + + # This function was designed to be portable across Python versions -- both # with older versions and with Python 3 after applying 2to3. def read(path): @@ -26,6 +53,14 @@ def read(path): f.close() +class MissingTags(object): + + """Contains the valid values for Renderer.missing_tags.""" + + ignore = 'ignore' + strict = 'strict' + + class PystacheError(Exception): """Base class for Pystache exceptions.""" pass diff --git a/pystache/context.py b/pystache/context.py index 8a95059..6715916 100644 --- a/pystache/context.py +++ b/pystache/context.py @@ -14,6 +14,9 @@ spec, we define these categories mutually exclusively as follows: """ +from pystache.common import PystacheError + + # This equals '__builtin__' in Python 2 and 'builtins' in Python 3. _BUILTIN_MODULE = type(0).__module__ @@ -55,8 +58,15 @@ def _get_value(context, key): # 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(context, key): + try: attr = getattr(context, key) + except AttributeError: + # TODO: distinguish the case of the attribute not existing from + # an AttributeError being raised by the call to the attribute. + # See the following issue for implementation ideas: + # http://bugs.python.org/issue7559 + pass + else: # TODO: consider using EAFP here instead. # http://docs.python.org/glossary.html#term-eafp if callable(attr): @@ -66,6 +76,21 @@ def _get_value(context, key): return _NOT_FOUND +class KeyNotFoundError(PystacheError): + + """ + An exception raised when a key is not found in a context stack. + + """ + + def __init__(self, key, details): + self.key = key + self.details = details + + def __str__(self): + return "Key %s not found: %s" % (repr(self.key), self.details) + + class ContextStack(object): """ @@ -175,7 +200,7 @@ class ContextStack(object): # TODO: add more unit tests for this. # TODO: update the docstring for dotted names. - def get(self, name, default=u''): + def get(self, name): """ Resolve a dotted name against the current context stack. @@ -245,18 +270,19 @@ class ContextStack(object): """ if name == '.': - # TODO: should we add a test case for an empty context stack? - return self.top() + try: + return self.top() + except IndexError: + raise KeyNotFoundError(".", "empty context stack") parts = name.split('.') - result = self._get_simple(parts[0]) + try: + result = self._get_simple(parts[0]) + except KeyNotFoundError: + raise KeyNotFoundError(name, "first part") for part in parts[1:]: - # TODO: consider using EAFP here instead. - # http://docs.python.org/glossary.html#term-eafp - if result is _NOT_FOUND: - break # The full context stack is not used to resolve the remaining parts. # From the spec-- # @@ -268,9 +294,10 @@ class ContextStack(object): # # TODO: make sure we have a test case for the above point. result = _get_value(result, part) - - if result is _NOT_FOUND: - return default + # TODO: consider using EAFP here instead. + # http://docs.python.org/glossary.html#term-eafp + if result is _NOT_FOUND: + raise KeyNotFoundError(name, "missing %s" % repr(part)) return result @@ -279,16 +306,12 @@ class ContextStack(object): Query the stack for a non-dotted name. """ - result = _NOT_FOUND - for item in reversed(self._stack): result = _get_value(item, name) - if result is _NOT_FOUND: - continue - # Otherwise, the key was found. - break + if result is not _NOT_FOUND: + return result - return result + raise KeyNotFoundError(name, "part missing") def push(self, item): """ diff --git a/pystache/defaults.py b/pystache/defaults.py index fcd04c3..bcfdf4c 100644 --- a/pystache/defaults.py +++ b/pystache/defaults.py @@ -17,6 +17,8 @@ except ImportError: import os import sys +from pystache.common import MissingTags + # How to handle encoding errors when decoding strings from str to unicode. # @@ -36,6 +38,12 @@ STRING_ENCODING = sys.getdefaultencoding() # strings that arise from files. FILE_ENCODING = sys.getdefaultencoding() +# The delimiters to start with when parsing. +DELIMITERS = (u'{{', u'}}') + +# How to handle missing tags when rendering a template. +MISSING_TAGS = MissingTags.ignore + # The starting list of directories in which to search for templates when # loading a template by file name. SEARCH_DIRS = [os.curdir] # i.e. ['.'] @@ -53,5 +61,5 @@ SEARCH_DIRS = [os.curdir] # i.e. ['.'] # TAG_ESCAPE = lambda u: escape(u, quote=True) -# The default template extension. +# The default template extension, without the leading dot. TEMPLATE_EXTENSION = 'mustache' diff --git a/pystache/init.py b/pystache/init.py index e9d854d..38bb1f5 100644 --- a/pystache/init.py +++ b/pystache/init.py @@ -5,6 +5,7 @@ This module contains the initialization logic called by __init__.py. """ +from pystache.parser import parse from pystache.renderer import Renderer from pystache.template_spec import TemplateSpec diff --git a/pystache/loader.py b/pystache/loader.py index 0fdadc5..d4a7e53 100644 --- a/pystache/loader.py +++ b/pystache/loader.py @@ -33,6 +33,8 @@ class Loader(object): """ Loads the template associated to a name or user-defined object. + All load_*() methods return the template as a unicode string. + """ def __init__(self, file_encoding=None, extension=None, to_unicode=None, @@ -42,9 +44,9 @@ class Loader(object): Arguments: - extension: the template file extension. Pass False for no - extension (i.e. to use extensionless template files). - Defaults to the package default. + extension: the template file extension, without the leading dot. + Pass False for no extension (e.g. to use extensionless template + files). Defaults to the package default. file_encoding: the name of the encoding to use when converting file contents to unicode. Defaults to the package default. @@ -119,17 +121,29 @@ class Loader(object): return self.unicode(b, encoding) - # TODO: unit-test this method. + def load_file(self, file_name): + """ + Find and return the template with the given file name. + + Arguments: + + file_name: the file name of the template. + + """ + locator = self._make_locator() + + path = locator.find_file(file_name, self.search_dirs) + + return self.read(path) + def load_name(self, name): """ - Find and return the template with the given name. + Find and return the template with the given template name. Arguments: name: the name of the template. - search_dirs: the list of directories in which to search. - """ locator = self._make_locator() diff --git a/pystache/locator.py b/pystache/locator.py index 2189cf2..30c5b01 100644 --- a/pystache/locator.py +++ b/pystache/locator.py @@ -21,9 +21,9 @@ class Locator(object): Arguments: - extension: the template file extension. Pass False for no - extension (i.e. to use extensionless template files). - Defaults to the package default. + extension: the template file extension, without the leading dot. + Pass False for no extension (e.g. to use extensionless template + files). Defaults to the package default. """ if extension is None: @@ -123,10 +123,29 @@ class Locator(object): return path + def find_file(self, file_name, search_dirs): + """ + Return the path to a template with the given file name. + + Arguments: + + file_name: the file name of the template. + + search_dirs: the list of directories in which to search. + + """ + return self._find_path_required(search_dirs, file_name) + def find_name(self, template_name, search_dirs): """ Return the path to a template with the given name. + Arguments: + + template_name: the name of the template. + + search_dirs: the list of directories in which to search. + """ file_name = self.make_file_name(template_name) diff --git a/pystache/parsed.py b/pystache/parsed.py index a37565b..372d96c 100644 --- a/pystache/parsed.py +++ b/pystache/parsed.py @@ -3,50 +3,48 @@ """ Exposes a class that represents a parsed (or compiled) template. -This module is meant only for internal use. - """ class ParsedTemplate(object): - def __init__(self, parse_tree): - """ - Arguments: + """ + Represents a parsed or compiled template. - parse_tree: a list, each element of which is either-- + An instance wraps a list of unicode strings and node objects. A node + object must have a `render(engine, stack)` method that accepts a + RenderEngine instance and a ContextStack instance and returns a unicode + string. - (1) a unicode string, or - (2) a "rendering" callable that accepts a ContextStack instance - and returns a unicode string. + """ - The possible rendering callables are the return values of the - following functions: + def __init__(self): + self._parse_tree = [] - * RenderEngine._make_get_escaped() - * RenderEngine._make_get_inverse() - * RenderEngine._make_get_literal() - * RenderEngine._make_get_partial() - * RenderEngine._make_get_section() + def __repr__(self): + return repr(self._parse_tree) + def add(self, node): """ - self._parse_tree = parse_tree + Arguments: - def __repr__(self): - return "[%s]" % (", ".join([repr(part) for part in self._parse_tree])) + node: a unicode string or node object instance. See the class + docstring for information. + + """ + self._parse_tree.append(node) - def render(self, context): + def render(self, engine, context): """ Returns: a string of type unicode. """ # We avoid use of the ternary operator for Python 2.4 support. - def get_unicode(val): - if callable(val): - return val(context) - return val + def get_unicode(node): + if type(node) is unicode: + return node + return node.render(engine, context) parts = map(get_unicode, self._parse_tree) s = ''.join(parts) return unicode(s) - diff --git a/pystache/parser.py b/pystache/parser.py index 4e05f3b..c6a171f 100644 --- a/pystache/parser.py +++ b/pystache/parser.py @@ -1,31 +1,51 @@ # coding: utf-8 """ -Provides a class for parsing template strings. - -This module is only meant for internal use by the renderengine module. +Exposes a parse() function to parse template strings. """ import re -from pystache.common import TemplateNotFoundError +from pystache import defaults from pystache.parsed import ParsedTemplate -DEFAULT_DELIMITERS = (u'{{', u'}}') END_OF_LINE_CHARACTERS = [u'\r', u'\n'] NON_BLANK_RE = re.compile(ur'^(.)', re.M) -def _compile_template_re(delimiters=None): +# TODO: add some unit tests for this. +# TODO: add a test case that checks for spurious spaces. +# TODO: add test cases for delimiters. +def parse(template, delimiters=None): """ - Return a regular expresssion object (re.RegexObject) instance. + Parse a unicode template string and return a ParsedTemplate instance. + + Arguments: + + template: a unicode template string. + + delimiters: a 2-tuple of delimiters. Defaults to the package default. + + Examples: + + >>> parsed = parse(u"Hey {{#who}}{{name}}!{{/who}}") + >>> print str(parsed).replace('u', '') # This is a hack to get the test to pass both in Python 2 and 3. + ['Hey ', _SectionNode(key='who', index_begin=12, index_end=21, parsed=[_EscapeNode(key='name'), '!'])] """ - if delimiters is None: - delimiters = DEFAULT_DELIMITERS + if type(template) is not unicode: + raise Exception("Template is not unicode: %s" % type(template)) + parser = _Parser(delimiters) + return parser.parse(template) + + +def _compile_template_re(delimiters): + """ + Return a regular expresssion object (re.RegexObject) instance. + """ # The possible tag type characters following the opening tag, # excluding "=" and "{". tag_types = "!>&/#^" @@ -54,34 +74,172 @@ class ParsingError(Exception): pass -class Parser(object): +## Node types - _delimiters = None - _template_re = None +def _format(obj, exclude=None): + if exclude is None: + exclude = [] + exclude.append('key') + attrs = obj.__dict__ + names = list(set(attrs.keys()) - set(exclude)) + names.sort() + names.insert(0, 'key') + args = ["%s=%s" % (name, repr(attrs[name])) for name in names] + return "%s(%s)" % (obj.__class__.__name__, ", ".join(args)) - def __init__(self, engine, delimiters=None): - """ - Construct an instance. - Arguments: +class _CommentNode(object): - engine: a RenderEngine instance. + def __repr__(self): + return _format(self) - """ + def render(self, engine, context): + return u'' + + +class _ChangeNode(object): + + def __init__(self, delimiters): + self.delimiters = delimiters + + def __repr__(self): + return _format(self) + + def render(self, engine, context): + return u'' + + +class _EscapeNode(object): + + def __init__(self, key): + self.key = key + + def __repr__(self): + return _format(self) + + def render(self, engine, context): + s = engine.fetch_string(context, self.key) + return engine.escape(s) + + +class _LiteralNode(object): + + def __init__(self, key): + self.key = key + + def __repr__(self): + return _format(self) + + def render(self, engine, context): + s = engine.fetch_string(context, self.key) + return engine.literal(s) + + +class _PartialNode(object): + + def __init__(self, key, indent): + self.key = key + self.indent = indent + + def __repr__(self): + return _format(self) + + def render(self, engine, context): + template = engine.resolve_partial(self.key) + # Indent before rendering. + template = re.sub(NON_BLANK_RE, self.indent + ur'\1', template) + + return engine.render(template, context) + + +class _InvertedNode(object): + + def __init__(self, key, parsed_section): + self.key = key + self.parsed_section = parsed_section + + def __repr__(self): + return _format(self) + + def render(self, engine, context): + # TODO: is there a bug because we are not using the same + # logic as in fetch_string()? + data = engine.resolve_context(context, self.key) + # Note that lambdas are considered truthy for inverted sections + # per the spec. + if data: + return u'' + return self.parsed_section.render(engine, context) + + +class _SectionNode(object): + + # TODO: the template_ and parsed_template_ arguments don't both seem + # to be necessary. Can we remove one of them? For example, if + # callable(data) is True, then the initial parsed_template isn't used. + def __init__(self, key, parsed, delimiters, template, index_begin, index_end): + self.delimiters = delimiters + self.key = key + self.parsed = parsed + self.template = template + self.index_begin = index_begin + self.index_end = index_end + + def __repr__(self): + return _format(self, exclude=['delimiters', 'template']) + + def render(self, engine, context): + values = engine.fetch_section_data(context, self.key) + + parts = [] + for val in values: + if callable(val): + # Lambdas special case section rendering and bypass pushing + # the data value onto the context stack. From the spec-- + # + # When used as the data value for a Section tag, the + # lambda MUST be treatable as an arity 1 function, and + # invoked as such (passing a String containing the + # unprocessed section contents). The returned value + # MUST be rendered against the current delimiters, then + # interpolated in place of the section. + # + # Also see-- + # + # https://github.com/defunkt/pystache/issues/113 + # + # TODO: should we check the arity? + val = val(self.template[self.index_begin:self.index_end]) + val = engine._render_value(val, context, delimiters=self.delimiters) + parts.append(val) + continue + + context.push(val) + parts.append(self.parsed.render(engine, context)) + context.pop() + + return unicode(''.join(parts)) + + +class _Parser(object): + + _delimiters = None + _template_re = None + + def __init__(self, delimiters=None): if delimiters is None: - delimiters = DEFAULT_DELIMITERS + delimiters = defaults.DELIMITERS self._delimiters = delimiters - self.engine = engine - def compile_template_re(self): + def _compile_delimiters(self): self._template_re = _compile_template_re(self._delimiters) def _change_delimiters(self, delimiters): self._delimiters = delimiters - self.compile_template_re() + self._compile_delimiters() - def parse(self, template, start_index=0, section_key=None): + def parse(self, template): """ Parse a template string starting at some index. @@ -98,11 +256,16 @@ class Parser(object): a ParsedTemplate instance. """ - parse_tree = [] - index = start_index + self._compile_delimiters() + + start_index = 0 + content_end_index, parsed_section, section_key = None, None, None + parsed_template = ParsedTemplate() + + states = [] while True: - match = self._template_re.search(template, index) + match = self._template_re.search(template, start_index) if match is None: break @@ -110,10 +273,6 @@ class Parser(object): match_index = match.start() end_index = match.end() - before_tag = template[index : match_index] - - parse_tree.append(before_tag) - matches = match.groupdict() # Normalize the matches dictionary. @@ -138,100 +297,82 @@ class Parser(object): if end_index < len(template): end_index += template[end_index] == '\n' and 1 or 0 elif leading_whitespace: - parse_tree.append(leading_whitespace) match_index += len(leading_whitespace) leading_whitespace = '' - if tag_type == '/': - if tag_key != section_key: - raise ParsingError("Section end tag mismatch: %s != %s" % (tag_key, section_key)) + # Avoid adding spurious empty strings to the parse tree. + if start_index != match_index: + parsed_template.add(template[start_index:match_index]) - return ParsedTemplate(parse_tree), match_index, end_index + start_index = end_index - index = self._handle_tag_type(template, parse_tree, tag_type, tag_key, leading_whitespace, end_index) + if tag_type in ('#', '^'): + # Cache current state. + state = (tag_type, end_index, section_key, parsed_template) + states.append(state) - # Save the rest of the template. - parse_tree.append(template[index:]) + # Initialize new state + section_key, parsed_template = tag_key, ParsedTemplate() + continue - return ParsedTemplate(parse_tree) - - def _parse_section(self, template, start_index, section_key): - """ - Parse the contents of a template section. - - Arguments: - - template: a unicode template string. + if tag_type == '/': + if tag_key != section_key: + raise ParsingError("Section end tag mismatch: %s != %s" % (tag_key, section_key)) - start_index: the string index at which the section contents begin. + # Restore previous state with newly found section data. + parsed_section = parsed_template - section_key: the tag key of the section. + (tag_type, section_start_index, section_key, parsed_template) = states.pop() + node = self._make_section_node(template, tag_type, tag_key, parsed_section, + section_start_index, match_index) - Returns: a 3-tuple: + else: + node = self._make_interpolation_node(tag_type, tag_key, leading_whitespace) - parsed_section: the section contents parsed as a ParsedTemplate - instance. + parsed_template.add(node) - content_end_index: the string index after the section contents. + # Avoid adding spurious empty strings to the parse tree. + if start_index != len(template): + parsed_template.add(template[start_index:]) - end_index: the string index after the closing section tag (and - including any trailing newlines). + return parsed_template + def _make_interpolation_node(self, tag_type, tag_key, leading_whitespace): """ - parsed_section, content_end_index, end_index = \ - self.parse(template=template, start_index=start_index, section_key=section_key) - - return parsed_section, template[start_index:content_end_index], end_index - - def _handle_tag_type(self, template, parse_tree, tag_type, tag_key, leading_whitespace, end_index): + Create and return a non-section node for the parse tree. + """ # TODO: switch to using a dictionary instead of a bunch of ifs and elifs. if tag_type == '!': - return end_index + return _CommentNode() if tag_type == '=': delimiters = tag_key.split() self._change_delimiters(delimiters) - return end_index - - engine = self.engine + return _ChangeNode(delimiters) if tag_type == '': + return _EscapeNode(tag_key) - func = engine._make_get_escaped(tag_key) - - elif tag_type == '&': + if tag_type == '&': + return _LiteralNode(tag_key) - func = engine._make_get_literal(tag_key) + if tag_type == '>': + return _PartialNode(tag_key, leading_whitespace) - elif tag_type == '#': + raise Exception("Invalid symbol for interpolation tag: %s" % repr(tag_type)) - parsed_section, section_contents, end_index = self._parse_section(template, end_index, tag_key) - func = engine._make_get_section(tag_key, parsed_section, section_contents, self._delimiters) - - elif tag_type == '^': - - parsed_section, section_contents, end_index = self._parse_section(template, end_index, tag_key) - func = engine._make_get_inverse(tag_key, parsed_section) - - elif tag_type == '>': - - try: - # TODO: make engine.load() and test it separately. - template = engine.load_partial(tag_key) - except TemplateNotFoundError: - template = u'' - - # Indent before rendering. - template = re.sub(NON_BLANK_RE, leading_whitespace + ur'\1', template) - - func = engine._make_get_partial(template) - - else: - - raise Exception("Unrecognized tag type: %s" % repr(tag_type)) + def _make_section_node(self, template, tag_type, tag_key, parsed_section, + section_start_index, section_end_index): + """ + Create and return a section node for the parse tree. - parse_tree.append(func) + """ + if tag_type == '#': + return _SectionNode(tag_key, parsed_section, self._delimiters, + template, section_start_index, section_end_index) - return end_index + if tag_type == '^': + return _InvertedNode(tag_key, parsed_section) + raise Exception("Invalid symbol for section tag: %s" % repr(tag_type)) diff --git a/pystache/renderengine.py b/pystache/renderengine.py index bdbb30a..c797b17 100644 --- a/pystache/renderengine.py +++ b/pystache/renderengine.py @@ -7,7 +7,16 @@ Defines a class responsible for rendering logic. import re -from pystache.parser import Parser +from pystache.common import is_string +from pystache.parser import parse + + +def context_get(stack, name): + """ + Find and return a name from a ContextStack instance. + + """ + return stack.get(name) class RenderEngine(object): @@ -29,15 +38,15 @@ class RenderEngine(object): """ - def __init__(self, load_partial=None, literal=None, escape=None): + # TODO: it would probably be better for the constructor to accept + # and set as an attribute a single RenderResolver instance + # that encapsulates the customizable aspects of converting + # strings and resolving partials and names from context. + def __init__(self, literal=None, escape=None, resolve_context=None, + resolve_partial=None, to_str=None): """ Arguments: - load_partial: the function to call when loading a partial. The - function should accept a string template name and return a - template string of type unicode (not a subclass). If the - template is not found, it should raise a TemplateNotFoundError. - literal: the function used to convert unescaped variable tag values to unicode, e.g. the value corresponding to a tag "{{{name}}}". The function should accept a string of type @@ -59,217 +68,114 @@ class RenderEngine(object): incoming strings of type markupsafe.Markup differently from plain unicode strings. + resolve_context: the function to call to resolve a name against + a context stack. The function should accept two positional + arguments: a ContextStack instance and a name to resolve. + + resolve_partial: the function to call when loading a partial. + The function should accept a template name string and return a + template string of type unicode (not a subclass). + + to_str: a function that accepts an object and returns a string (e.g. + the built-in function str). This function is used for string + coercion whenever a string is required (e.g. for converting None + or 0 to a string). + """ self.escape = escape self.literal = literal - self.load_partial = load_partial - - # TODO: rename context to stack throughout this module. - def _get_string_value(self, context, tag_name): + self.resolve_context = resolve_context + self.resolve_partial = resolve_partial + self.to_str = to_str + + # TODO: Rename context to stack throughout this module. + + # From the spec: + # + # When used as the data value for an Interpolation tag, the lambda + # MUST be treatable as an arity 0 function, and invoked as such. + # The returned value MUST be rendered against the default delimiters, + # then interpolated in place of the lambda. + # + def fetch_string(self, context, name): """ Get a value from the given context as a basestring instance. """ - val = context.get(tag_name) + val = self.resolve_context(context, name) if callable(val): - # According to the spec: - # - # When used as the data value for an Interpolation tag, - # the lambda MUST be treatable as an arity 0 function, - # and invoked as such. The returned value MUST be - # rendered against the default delimiters, then - # interpolated in place of the lambda. - template = val() - if not isinstance(template, basestring): - # In case the template is an integer, for example. - template = str(template) - if type(template) is not unicode: - template = self.literal(template) - val = self._render(template, context) - - if not isinstance(val, basestring): - val = str(val) + # Return because _render_value() is already a string. + return self._render_value(val(), context) + + if not is_string(val): + return self.to_str(val) return val - def _make_get_literal(self, name): - def get_literal(context): - """ - Returns: a string of type unicode. - - """ - s = self._get_string_value(context, name) - s = self.literal(s) - return s - - return get_literal - - def _make_get_escaped(self, name): - get_literal = self._make_get_literal(name) - - def get_escaped(context): - """ - Returns: a string of type unicode. - - """ - s = self._get_string_value(context, name) - s = self.escape(s) - return s - - return get_escaped - - def _make_get_partial(self, template): - def get_partial(context): - """ - Returns: a string of type unicode. - - """ - # TODO: the parsing should be done before calling this function. - return self._render(template, context) - - return get_partial - - def _make_get_inverse(self, name, parsed_template): - def get_inverse(context): - """ - Returns a string with type unicode. - - """ - # TODO: is there a bug because we are not using the same - # logic as in _get_string_value()? - data = context.get(name) - # Per the spec, lambdas in inverted sections are considered truthy. - if data: - return u'' - return parsed_template.render(context) - - return get_inverse - - # TODO: the template_ and parsed_template_ arguments don't both seem - # to be necessary. Can we remove one of them? For example, if - # callable(data) is True, then the initial parsed_template isn't used. - def _make_get_section(self, name, parsed_template_, template_, delims): - def get_section(context): - """ - Returns: a string of type unicode. - - """ - template = template_ - parsed_template = parsed_template_ - data = context.get(name) - - # From the spec: + def fetch_section_data(self, context, name): + """ + Fetch the value of a section as a list. + + """ + data = self.resolve_context(context, name) + + # From the spec: + # + # If the data is not of a list type, it is coerced into a list + # as follows: if the data is truthy (e.g. `!!data == true`), + # use a single-element list containing the data, otherwise use + # an empty list. + # + if not data: + data = [] + else: + # The least brittle way to determine whether something + # supports iteration is by trying to call iter() on it: # - # If the data is not of a list type, it is coerced into a list - # as follows: if the data is truthy (e.g. `!!data == true`), - # use a single-element list containing the data, otherwise use - # an empty list. + # http://docs.python.org/library/functions.html#iter # - if not data: - data = [] + # It is not sufficient, for example, to check whether the item + # implements __iter__ () (the iteration protocol). There is + # also __getitem__() (the sequence protocol). In Python 2, + # strings do not implement __iter__(), but in Python 3 they do. + try: + iter(data) + except TypeError: + # Then the value does not support iteration. + data = [data] else: - # The least brittle way to determine whether something - # supports iteration is by trying to call iter() on it: - # - # http://docs.python.org/library/functions.html#iter - # - # It is not sufficient, for example, to check whether the item - # implements __iter__ () (the iteration protocol). There is - # also __getitem__() (the sequence protocol). In Python 2, - # strings do not implement __iter__(), but in Python 3 they do. - try: - iter(data) - except TypeError: - # Then the value does not support iteration. + if is_string(data) or isinstance(data, dict): + # Do not treat strings and dicts (which are iterable) as lists. data = [data] - else: - if isinstance(data, (basestring, dict)): - # Do not treat strings and dicts (which are iterable) as lists. - data = [data] - # Otherwise, treat the value as a list. - - parts = [] - for element in data: - if callable(element): - # Lambdas special case section rendering and bypass pushing - # the data value onto the context stack. From the spec-- - # - # When used as the data value for a Section tag, the - # lambda MUST be treatable as an arity 1 function, and - # invoked as such (passing a String containing the - # unprocessed section contents). The returned value - # MUST be rendered against the current delimiters, then - # interpolated in place of the section. - # - # Also see-- - # - # https://github.com/defunkt/pystache/issues/113 - # - # TODO: should we check the arity? - new_template = element(template) - new_parsed_template = self._parse(new_template, delimiters=delims) - parts.append(new_parsed_template.render(context)) - continue - - context.push(element) - parts.append(parsed_template.render(context)) - context.pop() - - return unicode(''.join(parts)) - - return get_section - - def _parse(self, template, delimiters=None): - """ - Parse the given template, and return a ParsedTemplate instance. - - Arguments: + # Otherwise, treat the value as a list. - template: a template string of type unicode. + return data + def _render_value(self, val, context, delimiters=None): """ - parser = Parser(self, delimiters=delimiters) - parser.compile_template_re() + Render an arbitrary value. - return parser.parse(template=template) - - def _render(self, template, context): """ - Returns: a string of type unicode. - - Arguments: - - template: a template string of type unicode. - context: a ContextStack instance. - - """ - # We keep this type-check as an added check because this method is - # called with template strings coming from potentially externally- - # supplied functions like self.literal, self.load_partial, etc. - # Beyond this point, we have much better control over the type. - if type(template) is not unicode: - raise Exception("Argument 'template' not unicode: %s: %s" % (type(template), repr(template))) - - parsed_template = self._parse(template) - - return parsed_template.render(context) - - def render(self, template, context): + if not is_string(val): + # In case the template is an integer, for example. + val = self.to_str(val) + if type(val) is not unicode: + val = self.literal(val) + return self.render(val, context, delimiters) + + def render(self, template, context_stack, delimiters=None): """ - Return a template rendered as a string with type unicode. + Render a unicode template string, and return as unicode. Arguments: template: a template string of type unicode (but not a proper subclass of unicode). - context: a ContextStack instance. + context_stack: a ContextStack instance. """ - # Be strict but not too strict. In other words, accept str instead - # of unicode, but don't assume anything about the encoding (e.g. - # don't use self.literal). - template = unicode(template) + parsed_template = parse(template, delimiters) - return self._render(template, context) + return parsed_template.render(self, context_stack) diff --git a/pystache/renderer.py b/pystache/renderer.py index a3d4c57..ff6a90c 100644 --- a/pystache/renderer.py +++ b/pystache/renderer.py @@ -8,36 +8,26 @@ This module provides a Renderer class to render templates. import sys from pystache import defaults -from pystache.common import TemplateNotFoundError -from pystache.context import ContextStack +from pystache.common import TemplateNotFoundError, MissingTags, is_string +from pystache.context import ContextStack, KeyNotFoundError from pystache.loader import Loader -from pystache.renderengine import RenderEngine +from pystache.parsed import ParsedTemplate +from pystache.renderengine import context_get, RenderEngine from pystache.specloader import SpecLoader from pystache.template_spec import TemplateSpec -# TODO: come up with a better solution for this. One of the issues here -# is that in Python 3 there is no common base class for unicode strings -# and byte strings, and 2to3 seems to convert all of "str", "unicode", -# and "basestring" to Python 3's "str". -if sys.version_info < (3, ): - _STRING_TYPES = basestring -else: - # The latter evaluates to "bytes" in Python 3 -- even after conversion by 2to3. - _STRING_TYPES = (unicode, type(u"a".encode('utf-8'))) - - class Renderer(object): """ A class for rendering mustache templates. This class supports several rendering options which are described in - the constructor's docstring. Among these, the constructor supports - passing a custom partial loader. + the constructor's docstring. Other behavior can be customized by + subclassing this class. - Here is an example of rendering a template using a custom partial loader - that loads partials from a string-string dictionary. + For example, one can pass a string-string dictionary to the constructor + to bypass loading partials from the file system: >>> partials = {'partial': 'Hello, {{thing}}!'} >>> renderer = Renderer(partials=partials) @@ -45,16 +35,49 @@ class Renderer(object): >>> print renderer.render('{{>partial}}', {'thing': 'world'}) Hello, world! + To customize string coercion (e.g. to render False values as ''), one can + subclass this class. For example: + + class MyRenderer(Renderer): + def str_coerce(self, val): + if not val: + return '' + else: + return str(val) + """ def __init__(self, file_encoding=None, string_encoding=None, decode_errors=None, search_dirs=None, file_extension=None, - escape=None, partials=None): + escape=None, partials=None, missing_tags=None): """ Construct an instance. Arguments: + file_encoding: the name of the encoding to use by default when + reading template files. All templates are converted to unicode + prior to parsing. Defaults to the package default. + + string_encoding: the name of the encoding to use when converting + to unicode any byte strings (type str in Python 2) encountered + during the rendering process. This name will be passed as the + encoding argument to the built-in function unicode(). + Defaults to the package default. + + decode_errors: the string to pass as the errors argument to the + built-in function unicode() when converting byte strings to + unicode. Defaults to the package default. + + search_dirs: the list of directories in which to search when + loading a template by name or file name. If given a string, + the method interprets the string as a single directory. + Defaults to the package default. + + file_extension: the template file extension. Pass False for no + extension (i.e. to use extensionless template files). + Defaults to the package default. + partials: an object (e.g. a dictionary) for custom partial loading during the rendering process. The object should have a get() method that accepts a string @@ -67,10 +90,6 @@ class Renderer(object): the file system -- using relevant instance attributes like search_dirs, file_encoding, etc. - decode_errors: the string to pass as the errors argument to the - built-in function unicode() when converting str strings to - unicode. Defaults to the package default. - escape: the function used to escape variable tag values when rendering a template. The function should accept a unicode string (or subclass of unicode) and return an escaped string @@ -84,24 +103,9 @@ class Renderer(object): consider using markupsafe's escape function: markupsafe.escape(). This argument defaults to the package default. - file_encoding: the name of the default encoding to use when reading - template files. All templates are converted to unicode prior - to parsing. This encoding is used when reading template files - and converting them to unicode. Defaults to the package default. - - file_extension: the template file extension. Pass False for no - extension (i.e. to use extensionless template files). - Defaults to the package default. - - search_dirs: the list of directories in which to search when - loading a template by name or file name. If given a string, - the method interprets the string as a single directory. - Defaults to the package default. - - string_encoding: the name of the encoding to use when converting - to unicode any strings of type str encountered during the - rendering process. The name will be passed as the encoding - argument to the built-in function unicode(). Defaults to the + missing_tags: a string specifying how to handle missing tags. + If 'strict', an error is raised on a missing tag. If 'ignore', + the value of the tag is the empty string. Defaults to the package default. """ @@ -117,6 +121,9 @@ class Renderer(object): if file_extension is None: file_extension = defaults.TEMPLATE_EXTENSION + if missing_tags is None: + missing_tags = defaults.MISSING_TAGS + if search_dirs is None: search_dirs = defaults.SEARCH_DIRS @@ -131,6 +138,7 @@ class Renderer(object): self.escape = escape self.file_encoding = file_encoding self.file_extension = file_extension + self.missing_tags = missing_tags self.partials = partials self.search_dirs = search_dirs self.string_encoding = string_encoding @@ -148,6 +156,20 @@ class Renderer(object): """ return self._context + # We could not choose str() as the name because 2to3 renames the unicode() + # method of this class to str(). + def str_coerce(self, val): + """ + Coerce a non-string value to a string. + + This method is called whenever a non-string is encountered during the + rendering process when a string is needed (e.g. if a context value + for string interpolation is not a string). To customize string + coercion, you can override this method. + + """ + return str(val) + def _to_unicode_soft(self, s): """ Convert a basestring to unicode, preserving any unicode subclass. @@ -224,21 +246,21 @@ class Renderer(object): def _make_load_partial(self): """ - Return the load_partial function to pass to RenderEngine.__init__(). + Return a function that loads a partial by name. """ if self.partials is None: - load_template = self._make_load_template() - return load_template + return self._make_load_template() - # Otherwise, create a load_partial function from the custom partial - # loader that satisfies RenderEngine requirements (and that provides - # a nicer exception, etc). + # Otherwise, create a function from the custom partial loader. partials = self.partials def load_partial(name): + # TODO: consider using EAFP here instead. + # http://docs.python.org/glossary.html#term-eafp + # This would mean requiring that the custom partial loader + # raise a KeyError on name not found. template = partials.get(name) - if template is None: raise TemplateNotFoundError("Name %s not found in partials: %s" % (repr(name), type(partials))) @@ -248,42 +270,79 @@ class Renderer(object): return load_partial - def _make_render_engine(self): + def _is_missing_tags_strict(self): """ - Return a RenderEngine instance for rendering. + Return whether missing_tags is set to strict. """ - load_partial = self._make_load_partial() + val = self.missing_tags - engine = RenderEngine(load_partial=load_partial, - literal=self._to_unicode_hard, - escape=self._escape_to_unicode) - return engine + if val == MissingTags.strict: + return True + elif val == MissingTags.ignore: + return False - # TODO: add unit tests for this method. - def load_template(self, template_name): + raise Exception("Unsupported 'missing_tags' value: %s" % repr(val)) + + def _make_resolve_partial(self): """ - Load a template by name from the file system. + Return the resolve_partial function to pass to RenderEngine.__init__(). """ - load_template = self._make_load_template() - return load_template(template_name) + load_partial = self._make_load_partial() - def _render_string(self, template, *context, **kwargs): + if self._is_missing_tags_strict(): + return load_partial + # Otherwise, ignore missing tags. + + def resolve_partial(name): + try: + return load_partial(name) + except TemplateNotFoundError: + return u'' + + return resolve_partial + + def _make_resolve_context(self): """ - Render the given template string using the given context. + Return the resolve_context function to pass to RenderEngine.__init__(). """ - # RenderEngine.render() requires that the template string be unicode. - template = self._to_unicode_hard(template) + if self._is_missing_tags_strict(): + return context_get + # Otherwise, ignore missing tags. - context = ContextStack.create(*context, **kwargs) - self._context = context + def resolve_context(stack, name): + try: + return context_get(stack, name) + except KeyNotFoundError: + return u'' - engine = self._make_render_engine() - rendered = engine.render(template, context) + return resolve_context - return unicode(rendered) + def _make_render_engine(self): + """ + Return a RenderEngine instance for rendering. + + """ + resolve_context = self._make_resolve_context() + resolve_partial = self._make_resolve_partial() + + engine = RenderEngine(literal=self._to_unicode_hard, + escape=self._escape_to_unicode, + resolve_context=resolve_context, + resolve_partial=resolve_partial, + to_str=self.str_coerce) + return engine + + # TODO: add unit tests for this method. + def load_template(self, template_name): + """ + Load a template by name from the file system. + + """ + load_template = self._make_load_template() + return load_template(template_name) def _render_object(self, obj, *context, **kwargs): """ @@ -307,6 +366,17 @@ class Renderer(object): return self._render_string(template, *context, **kwargs) + def render_name(self, template_name, *context, **kwargs): + """ + Render the template with the given name using the given context. + + See the render() docstring for more information. + + """ + loader = self._make_loader() + template = loader.load_name(template_name) + return self._render_string(template, *context, **kwargs) + def render_path(self, template_path, *context, **kwargs): """ Render the template at the given path using the given context. @@ -319,24 +389,54 @@ class Renderer(object): return self._render_string(template, *context, **kwargs) + def _render_string(self, template, *context, **kwargs): + """ + Render the given template string using the given context. + + """ + # RenderEngine.render() requires that the template string be unicode. + template = self._to_unicode_hard(template) + + render_func = lambda engine, stack: engine.render(template, stack) + + return self._render_final(render_func, *context, **kwargs) + + # All calls to render() should end here because it prepares the + # context stack correctly. + def _render_final(self, render_func, *context, **kwargs): + """ + Arguments: + + render_func: a function that accepts a RenderEngine and ContextStack + instance and returns a template rendering as a unicode string. + + """ + stack = ContextStack.create(*context, **kwargs) + self._context = stack + + engine = self._make_render_engine() + + return render_func(engine, stack) + def render(self, template, *context, **kwargs): """ - Render the given template (or template object) using the given context. + Render the given template string, view template, or parsed template. - Returns the rendering as a unicode string. + Returns a unicode string. - Prior to rendering, templates of type str are converted to unicode - using the string_encoding and decode_errors attributes. See the - constructor docstring for more information. + Prior to rendering, this method will convert a template that is a + byte string (type str in Python 2) to unicode using the string_encoding + and decode_errors attributes. See the constructor docstring for + more information. Arguments: - template: a template string of type unicode or str, or an object - instance. If the argument is an object, the function first looks - for the template associated to the object by calling this class's - get_associated_template() method. The rendering process also - uses the passed object as the first element of the context stack - when rendering. + template: a template string that is unicode or a byte string, + a ParsedTemplate instance, or another object instance. In the + final case, the function first looks for the template associated + to the object by calling this class's get_associated_template() + method. The rendering process also uses the passed object as + the first element of the context stack when rendering. *context: zero or more dictionaries, ContextStack instances, or objects with which to populate the initial context stack. None @@ -350,8 +450,11 @@ class Renderer(object): all items in the *context list. """ - if isinstance(template, _STRING_TYPES): + if is_string(template): return self._render_string(template, *context, **kwargs) + if isinstance(template, ParsedTemplate): + render_func = lambda engine, stack: template.render(engine, stack) + return self._render_final(render_func, *context, **kwargs) # Otherwise, we assume the template is an object. return self._render_object(template, *context, **kwargs) diff --git a/pystache/specloader.py b/pystache/specloader.py index 3cb0f1a..3a77d4c 100644 --- a/pystache/specloader.py +++ b/pystache/specloader.py @@ -55,6 +55,9 @@ class SpecLoader(object): Find and return the path to the template associated to the instance. """ + if spec.template_path is not None: + return spec.template_path + dir_path, file_name = self._find_relative(spec) locator = self.loader._make_locator() diff --git a/pystache/template_spec.py b/pystache/template_spec.py index 76ce784..9e9f454 100644 --- a/pystache/template_spec.py +++ b/pystache/template_spec.py @@ -4,12 +4,11 @@ Provides a class to customize template information on a per-view basis. To customize template properties for a particular view, create that view -from a class that subclasses TemplateSpec. The "Spec" in TemplateSpec -stands for template information that is "special" or "specified". +from a class that subclasses TemplateSpec. The "spec" in TemplateSpec +stands for "special" or "specified" template information. """ -# TODO: finish the class docstring. class TemplateSpec(object): """ @@ -28,20 +27,27 @@ class TemplateSpec(object): template: the template as a string. - template_rel_path: the path to the template file, relative to the - directory containing the module defining the class. - - template_rel_directory: the directory containing the template file, relative - to the directory containing the module defining the class. + template_encoding: the encoding used by the template. template_extension: the template file extension. Defaults to "mustache". Pass False for no extension (i.e. extensionless template files). + template_name: the name of the template. + + template_path: absolute path to the template. + + template_rel_directory: the directory containing the template file, + relative to the directory containing the module defining the class. + + template_rel_path: the path to the template file, relative to the + directory containing the module defining the class. + """ template = None - template_rel_path = None - template_rel_directory = None - template_name = None - template_extension = None template_encoding = None + template_extension = None + template_name = None + template_path = None + template_rel_directory = None + template_rel_path = None diff --git a/pystache/tests/common.py b/pystache/tests/common.py index 24b24dc..99be4c8 100644 --- a/pystache/tests/common.py +++ b/pystache/tests/common.py @@ -22,7 +22,7 @@ PROJECT_DIR = os.path.join(PACKAGE_DIR, '..') SPEC_TEST_DIR = os.path.join(PROJECT_DIR, 'ext', 'spec', 'specs') # TEXT_DOCTEST_PATHS: the paths to text files (i.e. non-module files) # containing doctests. The paths should be relative to the project directory. -TEXT_DOCTEST_PATHS = ['README.rst'] +TEXT_DOCTEST_PATHS = ['README.md'] UNITTEST_FILE_PREFIX = "test_" @@ -43,7 +43,10 @@ def html_escape(u): return u.replace("'", ''') -def get_data_path(file_name): +def get_data_path(file_name=None): + """Return the path to a file in the test data directory.""" + if file_name is None: + file_name = "" return os.path.join(DATA_DIR, file_name) @@ -139,8 +142,7 @@ class AssertStringMixin: format = "%s" # Show both friendly and literal versions. - details = """String mismatch: %%s\ - + details = """String mismatch: %%s Expected: \"""%s\""" Actual: \"""%s\""" diff --git a/pystache/tests/data/locator/template.txt b/pystache/tests/data/locator/template.txt new file mode 100644 index 0000000..bef8160 --- /dev/null +++ b/pystache/tests/data/locator/template.txt @@ -0,0 +1 @@ +Test template file diff --git a/pystache/tests/doctesting.py b/pystache/tests/doctesting.py index 469c81e..1102b78 100644 --- a/pystache/tests/doctesting.py +++ b/pystache/tests/doctesting.py @@ -44,7 +44,11 @@ def get_doctests(text_file_dir): paths = [os.path.normpath(os.path.join(text_file_dir, path)) for path in TEXT_DOCTEST_PATHS] if sys.version_info >= (3,): - paths = _convert_paths(paths) + # Skip the README doctests in Python 3 for now because examples + # rendering to unicode do not give consistent results + # (e.g. 'foo' vs u'foo'). + # paths = _convert_paths(paths) + paths = [] suites = [] diff --git a/pystache/tests/main.py b/pystache/tests/main.py index de56c44..184122d 100644 --- a/pystache/tests/main.py +++ b/pystache/tests/main.py @@ -1,7 +1,7 @@ # coding: utf-8 """ -Exposes a run_tests() function that runs all tests in the project. +Exposes a main() function that runs all tests in the project. This module is for our test console script. @@ -10,7 +10,7 @@ This module is for our test console script. import os import sys import unittest -from unittest import TestProgram +from unittest import TestCase, TestProgram import pystache from pystache.tests.common import PACKAGE_DIR, PROJECT_DIR, SPEC_TEST_DIR, UNITTEST_FILE_PREFIX @@ -24,6 +24,58 @@ from pystache.tests.spectesting import get_spec_tests FROM_SOURCE_OPTION = "--from-source" +def make_extra_tests(text_doctest_dir, spec_test_dir): + tests = [] + + if text_doctest_dir is not None: + doctest_suites = get_doctests(text_doctest_dir) + tests.extend(doctest_suites) + + if spec_test_dir is not None: + spec_testcases = get_spec_tests(spec_test_dir) + tests.extend(spec_testcases) + + return unittest.TestSuite(tests) + + +def make_test_program_class(extra_tests): + """ + Return a subclass of unittest.TestProgram. + + """ + # The function unittest.main() is an alias for unittest.TestProgram's + # constructor. TestProgram's constructor does the following: + # + # 1. calls self.parseArgs(argv), + # 2. which in turn calls self.createTests(). + # 3. then the constructor calls self.runTests(). + # + # The createTests() method sets the self.test attribute by calling one + # of self.testLoader's "loadTests" methods. Each loadTest method returns + # a unittest.TestSuite instance. Thus, self.test is set to a TestSuite + # instance prior to calling runTests(). + class PystacheTestProgram(TestProgram): + + """ + Instantiating an instance of this class runs all tests. + + """ + + def createTests(self): + """ + Load tests and set self.test to a unittest.TestSuite instance + + Compare-- + + http://docs.python.org/library/unittest.html#unittest.TestSuite + + """ + super(PystacheTestProgram, self).createTests() + self.test.addTests(extra_tests) + + return PystacheTestProgram + + # Do not include "test" in this function's name to avoid it getting # picked up by nosetests. def main(sys_argv): @@ -52,7 +104,14 @@ def main(sys_argv): sys_argv.pop(1) except IndexError: if should_source_exist: - spec_test_dir = SPEC_TEST_DIR + if not os.path.exists(SPEC_TEST_DIR): + # Then the user is probably using a downloaded sdist rather + # than a repository clone (since the sdist does not include + # the spec test directory). + print("pystache: skipping spec tests: spec test directory " + "not found") + else: + spec_test_dir = SPEC_TEST_DIR try: # TODO: use optparse command options instead. @@ -71,16 +130,17 @@ def main(sys_argv): # Add the current module for unit tests contained here. sys_argv.append(__name__) - _PystacheTestProgram._text_doctest_dir = project_dir - _PystacheTestProgram._spec_test_dir = spec_test_dir SetupTests.project_dir = project_dir + extra_tests = make_extra_tests(project_dir, spec_test_dir) + test_program_class = make_test_program_class(extra_tests) + # We pass None for the module because we do not want the unittest # module to resolve module names relative to a given module. # (This would require importing all of the unittest modules from # this module.) See the loadTestsFromName() method of the # unittest.TestLoader class for more details on this parameter. - _PystacheTestProgram(argv=sys_argv, module=None) + test_program_class(argv=sys_argv, module=None) # No need to return since unitttest.main() exits. @@ -103,7 +163,7 @@ def _discover_test_modules(package_dir): return names -class SetupTests(unittest.TestCase): +class SetupTests(TestCase): """Tests about setup.py.""" @@ -123,33 +183,3 @@ class SetupTests(unittest.TestCase): self.assertEqual(VERSION, pystache.__version__) finally: sys.path = original_path - - -# The function unittest.main() is an alias for unittest.TestProgram's -# constructor. TestProgram's constructor calls self.runTests() as its -# final step, which expects self.test to be set. The constructor sets -# the self.test attribute by calling one of self.testLoader's "loadTests" -# methods prior to callint self.runTests(). Each loadTest method returns -# a unittest.TestSuite instance. Thus, self.test is set to a TestSuite -# instance prior to calling runTests(). -class _PystacheTestProgram(TestProgram): - - """ - Instantiating an instance of this class runs all tests. - - """ - - def runTests(self): - # self.test is a unittest.TestSuite instance: - # http://docs.python.org/library/unittest.html#unittest.TestSuite - tests = self.test - - if self._text_doctest_dir is not None: - doctest_suites = get_doctests(self._text_doctest_dir) - tests.addTests(doctest_suites) - - if self._spec_test_dir is not None: - spec_testcases = get_spec_tests(self._spec_test_dir) - tests.addTests(spec_testcases) - - TestProgram.runTests(self) diff --git a/pystache/tests/test___init__.py b/pystache/tests/test___init__.py index d4f3526..eae42c1 100644 --- a/pystache/tests/test___init__.py +++ b/pystache/tests/test___init__.py @@ -23,7 +23,7 @@ class InitTests(unittest.TestCase): """ actual = set(GLOBALS_PYSTACHE_IMPORTED) - set(GLOBALS_INITIAL) - expected = set(['render', 'Renderer', 'TemplateSpec', 'GLOBALS_INITIAL']) + expected = set(['parse', 'render', 'Renderer', 'TemplateSpec', 'GLOBALS_INITIAL']) self.assertEqual(actual, expected) diff --git a/pystache/tests/test_context.py b/pystache/tests/test_context.py index d432428..238e4b0 100644 --- a/pystache/tests/test_context.py +++ b/pystache/tests/test_context.py @@ -8,10 +8,8 @@ Unit tests of context.py. from datetime import datetime 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, AssertStringMixin, Attachable +from pystache.context import _NOT_FOUND, _get_value, KeyNotFoundError, ContextStack +from pystache.tests.common import AssertIsMixin, AssertStringMixin, AssertExceptionMixin, Attachable class SimpleObject(object): @@ -39,7 +37,7 @@ class DictLike(object): return self._dict[key] -class GetValueTests(unittest.TestCase, AssertIsMixin): +class GetValueTestCase(unittest.TestCase, AssertIsMixin): """Test context._get_value().""" @@ -147,6 +145,26 @@ class GetValueTests(unittest.TestCase, AssertIsMixin): self.assertEqual(item["foo"], "bar") self.assertNotFound(item, "foo") + def test_object__property__raising_exception(self): + """ + Test getting a property that raises an exception. + + """ + class Foo(object): + + @property + def bar(self): + return 1 + + @property + def baz(self): + raise ValueError("test") + + foo = Foo() + self.assertEqual(_get_value(foo, 'bar'), 1) + self.assertNotFound(foo, 'missing') + self.assertRaises(ValueError, _get_value, foo, 'baz') + ### Case: the item is an instance of a built-in type. def test_built_in_type__integer(self): @@ -204,7 +222,8 @@ class GetValueTests(unittest.TestCase, AssertIsMixin): self.assertNotFound(item2, 'pop') -class ContextStackTests(unittest.TestCase, AssertIsMixin, AssertStringMixin): +class ContextStackTestCase(unittest.TestCase, AssertIsMixin, AssertStringMixin, + AssertExceptionMixin): """ Test the ContextStack class. @@ -306,6 +325,24 @@ class ContextStackTests(unittest.TestCase, AssertIsMixin, AssertStringMixin): context = ContextStack.create({'foo': 'bar'}, foo='buzz') self.assertEqual(context.get('foo'), 'buzz') + ## Test the get() method. + + def test_get__single_dot(self): + """ + Test getting a single dot ("."). + + """ + context = ContextStack("a", "b") + self.assertEqual(context.get("."), "b") + + def test_get__single_dot__missing(self): + """ + Test getting a single dot (".") with an empty context stack. + + """ + context = ContextStack() + self.assertException(KeyNotFoundError, "Key '.' not found: empty context stack", context.get, ".") + def test_get__key_present(self): """ Test getting a key. @@ -320,15 +357,7 @@ class ContextStackTests(unittest.TestCase, AssertIsMixin, AssertStringMixin): """ context = ContextStack() - self.assertString(context.get("foo"), u'') - - def test_get__default(self): - """ - Test that get() respects the default value. - - """ - context = ContextStack() - self.assertEqual(context.get("foo", "bar"), "bar") + self.assertException(KeyNotFoundError, "Key 'foo' not found: first part", context.get, "foo") def test_get__precedence(self): """ @@ -424,10 +453,10 @@ class ContextStackTests(unittest.TestCase, AssertIsMixin, AssertStringMixin): def test_dot_notation__missing_attr_or_key(self): name = "foo.bar.baz.bak" stack = ContextStack({"foo": {"bar": {}}}) - self.assertString(stack.get(name), u'') + self.assertException(KeyNotFoundError, "Key 'foo.bar.baz.bak' not found: missing 'baz'", stack.get, name) stack = ContextStack({"foo": Attachable(bar=Attachable())}) - self.assertString(stack.get(name), u'') + self.assertException(KeyNotFoundError, "Key 'foo.bar.baz.bak' not found: missing 'baz'", stack.get, name) def test_dot_notation__missing_part_terminates_search(self): """ @@ -451,7 +480,7 @@ class ContextStackTests(unittest.TestCase, AssertIsMixin, AssertStringMixin): """ stack = ContextStack({'a': {'b': 'A.B'}}, {'a': 'A'}) self.assertEqual(stack.get('a'), 'A') - self.assertString(stack.get('a.b'), u'') + self.assertException(KeyNotFoundError, "Key 'a.b' not found: missing 'b'", stack.get, "a.b") stack.pop() self.assertEqual(stack.get('a.b'), 'A.B') diff --git a/pystache/tests/test_defaults.py b/pystache/tests/test_defaults.py new file mode 100644 index 0000000..c78ea7c --- /dev/null +++ b/pystache/tests/test_defaults.py @@ -0,0 +1,68 @@ +# coding: utf-8 + +""" +Unit tests for defaults.py. + +""" + +import unittest + +import pystache + +from pystache.tests.common import AssertStringMixin + + +# TODO: make sure each default has at least one test. +class DefaultsConfigurableTestCase(unittest.TestCase, AssertStringMixin): + + """Tests that the user can change the defaults at runtime.""" + + # TODO: switch to using a context manager after 2.4 is deprecated. + def setUp(self): + """Save the defaults.""" + defaults = [ + 'DECODE_ERRORS', 'DELIMITERS', + 'FILE_ENCODING', 'MISSING_TAGS', + 'SEARCH_DIRS', 'STRING_ENCODING', + 'TAG_ESCAPE', 'TEMPLATE_EXTENSION' + ] + self.saved = {} + for e in defaults: + self.saved[e] = getattr(pystache.defaults, e) + + def tearDown(self): + for key, value in self.saved.items(): + setattr(pystache.defaults, key, value) + + def test_tag_escape(self): + """Test that changes to defaults.TAG_ESCAPE take effect.""" + template = u"{{foo}}" + context = {'foo': '<'} + actual = pystache.render(template, context) + self.assertString(actual, u"<") + + pystache.defaults.TAG_ESCAPE = lambda u: u + actual = pystache.render(template, context) + self.assertString(actual, u"<") + + def test_delimiters(self): + """Test that changes to defaults.DELIMITERS take effect.""" + template = u"[[foo]]{{foo}}" + context = {'foo': 'FOO'} + actual = pystache.render(template, context) + self.assertString(actual, u"[[foo]]FOO") + + pystache.defaults.DELIMITERS = ('[[', ']]') + actual = pystache.render(template, context) + self.assertString(actual, u"FOO{{foo}}") + + def test_missing_tags(self): + """Test that changes to defaults.MISSING_TAGS take effect.""" + template = u"{{foo}}" + context = {} + actual = pystache.render(template, context) + self.assertString(actual, u"") + + pystache.defaults.MISSING_TAGS = 'strict' + self.assertRaises(pystache.context.KeyNotFoundError, + pystache.render, template, context) diff --git a/pystache/tests/test_loader.py b/pystache/tests/test_loader.py index c47239c..f2c2187 100644 --- a/pystache/tests/test_loader.py +++ b/pystache/tests/test_loader.py @@ -14,6 +14,10 @@ from pystache import defaults from pystache.loader import Loader +# We use the same directory as the locator tests for now. +LOADER_DATA_DIR = os.path.join(DATA_DIR, 'locator') + + class LoaderTests(unittest.TestCase, AssertStringMixin, SetupDefaults): def setUp(self): @@ -178,7 +182,7 @@ class LoaderTests(unittest.TestCase, AssertStringMixin, SetupDefaults): actual = loader.read(path, encoding='utf-8') self.assertString(actual, u'non-ascii: é') - def test_loader__to_unicode__attribute(self): + def test_read__to_unicode__attribute(self): """ Test read(): to_unicode attribute respected. @@ -192,3 +196,14 @@ class LoaderTests(unittest.TestCase, AssertStringMixin, SetupDefaults): #actual = loader.read(path) #self.assertString(actual, u'non-ascii: ') + def test_load_file(self): + loader = Loader(search_dirs=[DATA_DIR, LOADER_DATA_DIR]) + template = loader.load_file('template.txt') + self.assertEqual(template, 'Test template file\n') + + def test_load_name(self): + loader = Loader(search_dirs=[DATA_DIR, LOADER_DATA_DIR], + extension='txt') + template = loader.load_name('template') + self.assertEqual(template, 'Test template file\n') + diff --git a/pystache/tests/test_locator.py b/pystache/tests/test_locator.py index f17a289..ee1c2ff 100644 --- a/pystache/tests/test_locator.py +++ b/pystache/tests/test_locator.py @@ -19,6 +19,9 @@ from pystache.tests.common import DATA_DIR, EXAMPLES_DIR, AssertExceptionMixin from pystache.tests.data.views import SayHello +LOCATOR_DATA_DIR = os.path.join(DATA_DIR, 'locator') + + class LocatorTests(unittest.TestCase, AssertExceptionMixin): def _locator(self): @@ -53,7 +56,17 @@ class LocatorTests(unittest.TestCase, AssertExceptionMixin): def test_get_object_directory__not_hasattr_module(self): locator = Locator() - obj = datetime(2000, 1, 1) + # Previously, we used a genuine object -- a datetime instance -- + # because datetime instances did not have the __module__ attribute + # in CPython. See, for example-- + # + # http://bugs.python.org/issue15223 + # + # However, since datetime instances do have the __module__ attribute + # in PyPy, we needed to switch to something else once we added + # support for PyPi. This was so that our test runs would pass + # in all systems. + obj = "abc" self.assertFalse(hasattr(obj, '__module__')) self.assertEqual(locator.get_object_directory(obj), None) @@ -77,6 +90,13 @@ class LocatorTests(unittest.TestCase, AssertExceptionMixin): self.assertEqual(locator.make_file_name('foo', template_extension='bar'), 'foo.bar') + def test_find_file(self): + locator = Locator() + path = locator.find_file('template.txt', [LOCATOR_DATA_DIR]) + + expected_path = os.path.join(LOCATOR_DATA_DIR, 'template.txt') + self.assertEqual(path, expected_path) + def test_find_name(self): locator = Locator() path = locator.find_name(search_dirs=[EXAMPLES_DIR], template_name='simple') @@ -97,7 +117,7 @@ class LocatorTests(unittest.TestCase, AssertExceptionMixin): locator = Locator() dir1 = DATA_DIR - dir2 = os.path.join(DATA_DIR, 'locator') + dir2 = LOCATOR_DATA_DIR self.assertTrue(locator.find_name(search_dirs=[dir1], template_name='duplicate')) self.assertTrue(locator.find_name(search_dirs=[dir2], template_name='duplicate')) diff --git a/pystache/tests/test_parser.py b/pystache/tests/test_parser.py index 4aa0959..92248ea 100644 --- a/pystache/tests/test_parser.py +++ b/pystache/tests/test_parser.py @@ -7,6 +7,7 @@ Unit tests of parser.py. import unittest +from pystache.defaults import DELIMITERS from pystache.parser import _compile_template_re as make_re @@ -19,7 +20,7 @@ class RegularExpressionTestCase(unittest.TestCase): Test getting a key from a dictionary. """ - re = make_re() + re = make_re(DELIMITERS) match = re.search("b {{test}}") self.assertEqual(match.start(), 1) diff --git a/pystache/tests/test_renderengine.py b/pystache/tests/test_renderengine.py index b13e246..db916f7 100644 --- a/pystache/tests/test_renderengine.py +++ b/pystache/tests/test_renderengine.py @@ -5,13 +5,23 @@ Unit tests of renderengine.py. """ +import sys import unittest -from pystache.context import ContextStack +from pystache.context import ContextStack, KeyNotFoundError from pystache import defaults from pystache.parser import ParsingError -from pystache.renderengine import RenderEngine -from pystache.tests.common import AssertStringMixin, Attachable +from pystache.renderer import Renderer +from pystache.renderengine import context_get, RenderEngine +from pystache.tests.common import AssertStringMixin, AssertExceptionMixin, Attachable + + +def _get_unicode_char(): + if sys.version_info < (3, ): + return 'u' + return '' + +_UNICODE_CHAR = _get_unicode_char() def mock_literal(s): @@ -45,14 +55,16 @@ class RenderEngineTestCase(unittest.TestCase): """ # In real-life, these arguments would be functions - engine = RenderEngine(load_partial="foo", literal="literal", escape="escape") + engine = RenderEngine(resolve_partial="foo", literal="literal", + escape="escape", to_str="str") self.assertEqual(engine.escape, "escape") self.assertEqual(engine.literal, "literal") - self.assertEqual(engine.load_partial, "foo") + self.assertEqual(engine.resolve_partial, "foo") + self.assertEqual(engine.to_str, "str") -class RenderTests(unittest.TestCase, AssertStringMixin): +class RenderTests(unittest.TestCase, AssertStringMixin, AssertExceptionMixin): """ Tests RenderEngine.render(). @@ -68,8 +80,9 @@ class RenderTests(unittest.TestCase, AssertStringMixin): Create and return a default RenderEngine for testing. """ - escape = defaults.TAG_ESCAPE - engine = RenderEngine(literal=unicode, escape=escape, load_partial=None) + renderer = Renderer(string_encoding='utf-8', missing_tags='strict') + engine = renderer._make_render_engine() + return engine def _assert_render(self, expected, template, *context, **kwargs): @@ -81,25 +94,26 @@ class RenderTests(unittest.TestCase, AssertStringMixin): engine = kwargs.get('engine', self._engine()) if partials is not None: - engine.load_partial = lambda key: unicode(partials[key]) + engine.resolve_partial = lambda key: unicode(partials[key]) context = ContextStack(*context) - actual = engine.render(template, context) + # RenderEngine.render() only accepts unicode template strings. + actual = engine.render(unicode(template), context) self.assertString(actual=actual, expected=expected) def test_render(self): self._assert_render(u'Hi Mom', 'Hi {{person}}', {'person': 'Mom'}) - def test__load_partial(self): + def test__resolve_partial(self): """ Test that render() uses the load_template attribute. """ engine = self._engine() partials = {'partial': u"{{person}}"} - engine.load_partial = lambda key: partials[key] + engine.resolve_partial = lambda key: partials[key] self._assert_render(u'Hi Mom', 'Hi {{>partial}}', {'person': 'Mom'}, engine=engine) @@ -170,6 +184,47 @@ class RenderTests(unittest.TestCase, AssertStringMixin): self._assert_render(u'**bar bar**', template, context, engine=engine) + # Custom to_str for testing purposes. + def _to_str(self, val): + if not val: + return '' + else: + return str(val) + + def test_to_str(self): + """Test the to_str attribute.""" + engine = self._engine() + template = '{{value}}' + context = {'value': None} + + self._assert_render(u'None', template, context, engine=engine) + engine.to_str = self._to_str + self._assert_render(u'', template, context, engine=engine) + + def test_to_str__lambda(self): + """Test the to_str attribute for a lambda.""" + engine = self._engine() + template = '{{value}}' + context = {'value': lambda: None} + + self._assert_render(u'None', template, context, engine=engine) + engine.to_str = self._to_str + self._assert_render(u'', template, context, engine=engine) + + def test_to_str__section_list(self): + """Test the to_str attribute for a section list.""" + engine = self._engine() + template = '{{#list}}{{.}}{{/list}}' + context = {'list': [None, None]} + + self._assert_render(u'NoneNone', template, context, engine=engine) + engine.to_str = self._to_str + self._assert_render(u'', template, context, engine=engine) + + def test_to_str__section_lambda(self): + # TODO: add a test for a "method with an arity of 1". + pass + def test__non_basestring__literal_and_escaped(self): """ Test a context value that is not a basestring instance. @@ -285,6 +340,16 @@ class RenderTests(unittest.TestCase, AssertStringMixin): context = {'section': item, attr_name: 7} self._assert_render(u'7', template, context) + # This test is also important for testing 2to3. + def test_interpolation__nonascii_nonunicode(self): + """ + Test a tag whose value is a non-ascii, non-unicode string. + + """ + template = '{{nonascii}}' + context = {'nonascii': u'abcdé'.encode('utf-8')} + self._assert_render(u'abcdé', template, context) + def test_implicit_iterator__literal(self): """ Test an implicit iterator in a literal tag. @@ -343,6 +408,28 @@ class RenderTests(unittest.TestCase, AssertStringMixin): self._assert_render(u'unescaped: < escaped: <', template, context, engine=engine, partials=partials) + ## Test cases related specifically to lambdas. + + # This test is also important for testing 2to3. + def test_section__nonascii_nonunicode(self): + """ + Test a section whose value is a non-ascii, non-unicode string. + + """ + template = '{{#nonascii}}{{.}}{{/nonascii}}' + context = {'nonascii': u'abcdé'.encode('utf-8')} + self._assert_render(u'abcdé', template, context) + + # This test is also important for testing 2to3. + def test_lambda__returning_nonascii_nonunicode(self): + """ + Test a lambda tag value returning a non-ascii, non-unicode string. + + """ + template = '{{lambda}}' + context = {'lambda': lambda: u'abcdé'.encode('utf-8')} + self._assert_render(u'abcdé', template, context) + ## Test cases related specifically to sections. def test_section__end_tag_with_no_start_tag(self): @@ -461,6 +548,25 @@ class RenderTests(unittest.TestCase, AssertStringMixin): context = {'test': (lambda text: 'Hi %s' % text)} self._assert_render(u'Hi Mom', template, context) + # This test is also important for testing 2to3. + def test_section__lambda__returning_nonascii_nonunicode(self): + """ + Test a lambda section value returning a non-ascii, non-unicode string. + + """ + template = '{{#lambda}}{{/lambda}}' + context = {'lambda': lambda text: u'abcdé'.encode('utf-8')} + self._assert_render(u'abcdé', template, context) + + def test_section__lambda__returning_nonstring(self): + """ + Test a lambda section value returning a non-string. + + """ + template = '{{#lambda}}foo{{/lambda}}' + context = {'lambda': lambda text: len(text)} + self._assert_render(u'3', template, context) + def test_section__iterable(self): """ Check that objects supporting iteration (aside from dicts) behave like lists. @@ -609,33 +715,15 @@ class RenderTests(unittest.TestCase, AssertStringMixin): 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}}.""" + I see you're back from {{person.travels.last.country.city}}.""" expected = u"""Hello, Mr. Pither. - I see you're back from Cornwall. - I'm missing some of your details: .""" + I see you're back from Cornwall.""" context = {'person': {'name': {'firstname': 'unknown', 'lastname': 'Pither'}, 'travels': {'last': {'country': {'city': 'Cornwall'}}}, 'details': {'public': 'likes cycling'}}} @@ -667,6 +755,15 @@ class RenderTests(unittest.TestCase, AssertStringMixin): 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) + + template = '{{a.b}}' + self._assert_render(u'A.B', template, context) + + template = '{{#c}}{{a}}{{/c}}' + self._assert_render(u'A', template, context) + + template = '{{#c}}{{a.b}}{{/c}}' + self.assertException(KeyNotFoundError, "Key %(unicode)s'a.b' not found: missing %(unicode)s'b'" % + {'unicode': _UNICODE_CHAR}, + self._assert_render, 'A.B :: (A :: )', template, context) diff --git a/pystache/tests/test_renderer.py b/pystache/tests/test_renderer.py index f04c799..0dbe0d9 100644 --- a/pystache/tests/test_renderer.py +++ b/pystache/tests/test_renderer.py @@ -14,6 +14,7 @@ from examples.simple import Simple from pystache import Renderer from pystache import TemplateSpec from pystache.common import TemplateNotFoundError +from pystache.context import ContextStack, KeyNotFoundError from pystache.loader import Loader from pystache.tests.common import get_data_path, AssertStringMixin, AssertExceptionMixin @@ -124,6 +125,22 @@ class RendererInitTestCase(unittest.TestCase): renderer = Renderer(file_extension='foo') self.assertEqual(renderer.file_extension, 'foo') + def test_missing_tags(self): + """ + Check that the missing_tags attribute is set correctly. + + """ + renderer = Renderer(missing_tags='foo') + self.assertEqual(renderer.missing_tags, 'foo') + + def test_missing_tags__default(self): + """ + Check the missing_tags default. + + """ + renderer = Renderer() + self.assertEqual(renderer.missing_tags, 'ignore') + def test_search_dirs__default(self): """ Check the search_dirs default. @@ -319,37 +336,44 @@ class RendererTests(unittest.TestCase, AssertStringMixin): renderer.string_encoding = 'utf_8' self.assertEqual(renderer.render(template), u"déf") - def test_make_load_partial(self): + def test_make_resolve_partial(self): """ - Test the _make_load_partial() method. + Test the _make_resolve_partial() method. """ renderer = Renderer() renderer.partials = {'foo': 'bar'} - load_partial = renderer._make_load_partial() + resolve_partial = renderer._make_resolve_partial() - actual = load_partial('foo') + actual = resolve_partial('foo') self.assertEqual(actual, 'bar') self.assertEqual(type(actual), unicode, "RenderEngine requires that " - "load_partial return unicode strings.") + "resolve_partial return unicode strings.") - def test_make_load_partial__unicode(self): + def test_make_resolve_partial__unicode(self): """ - Test _make_load_partial(): that load_partial doesn't "double-decode" Unicode. + Test _make_resolve_partial(): that resolve_partial doesn't "double-decode" Unicode. """ renderer = Renderer() renderer.partials = {'partial': 'foo'} - load_partial = renderer._make_load_partial() - self.assertEqual(load_partial("partial"), "foo") + resolve_partial = renderer._make_resolve_partial() + self.assertEqual(resolve_partial("partial"), "foo") # Now with a value that is already unicode. renderer.partials = {'partial': u'foo'} - load_partial = renderer._make_load_partial() + resolve_partial = renderer._make_resolve_partial() # If the next line failed, we would get the following error: # TypeError: decoding Unicode is not supported - self.assertEqual(load_partial("partial"), "foo") + self.assertEqual(resolve_partial("partial"), "foo") + + def test_render_name(self): + """Test the render_name() method.""" + data_dir = get_data_path() + renderer = Renderer(search_dirs=data_dir) + actual = renderer.render_name("say_hello", to='foo') + self.assertString(actual, u"Hello, foo") def test_render_path(self): """ @@ -401,12 +425,45 @@ class RendererTests(unittest.TestCase, AssertStringMixin): actual = renderer.render(view) self.assertEqual('Hi pizza!', actual) + def test_custom_string_coercion_via_assignment(self): + """ + Test that string coercion can be customized via attribute assignment. + + """ + renderer = self._renderer() + def to_str(val): + if not val: + return '' + else: + return str(val) + + self.assertEqual(renderer.render('{{value}}', value=None), 'None') + renderer.str_coerce = to_str + self.assertEqual(renderer.render('{{value}}', value=None), '') + + def test_custom_string_coercion_via_subclassing(self): + """ + Test that string coercion can be customized via subclassing. + + """ + class MyRenderer(Renderer): + def str_coerce(self, val): + if not val: + return '' + else: + return str(val) + renderer1 = Renderer() + renderer2 = MyRenderer() + + self.assertEqual(renderer1.render('{{value}}', value=None), 'None') + self.assertEqual(renderer2.render('{{value}}', value=None), '') + # By testing that Renderer.render() constructs the right RenderEngine, # we no longer need to exercise all rendering code paths through # the Renderer. It suffices to test rendering paths through the # RenderEngine for the same amount of code coverage. -class Renderer_MakeRenderEngineTests(unittest.TestCase, AssertExceptionMixin): +class Renderer_MakeRenderEngineTests(unittest.TestCase, AssertStringMixin, AssertExceptionMixin): """ Check the RenderEngine returned by Renderer._make_render_engine(). @@ -420,11 +477,11 @@ class Renderer_MakeRenderEngineTests(unittest.TestCase, AssertExceptionMixin): """ return _make_renderer() - ## Test the engine's load_partial attribute. + ## Test the engine's resolve_partial attribute. - def test__load_partial__returns_unicode(self): + def test__resolve_partial__returns_unicode(self): """ - Check that load_partial returns unicode (and not a subclass). + Check that resolve_partial returns unicode (and not a subclass). """ class MyUnicode(unicode): @@ -436,43 +493,70 @@ class Renderer_MakeRenderEngineTests(unittest.TestCase, AssertExceptionMixin): engine = renderer._make_render_engine() - actual = engine.load_partial('str') + actual = engine.resolve_partial('str') self.assertEqual(actual, "foo") self.assertEqual(type(actual), unicode) # Check that unicode subclasses are not preserved. - actual = engine.load_partial('subclass') + actual = engine.resolve_partial('subclass') self.assertEqual(actual, "abc") self.assertEqual(type(actual), unicode) - def test__load_partial__not_found__default(self): + def test__resolve_partial__not_found(self): + """ + Check that resolve_partial returns the empty string when a template is not found. + + """ + renderer = Renderer() + + engine = renderer._make_render_engine() + resolve_partial = engine.resolve_partial + + self.assertString(resolve_partial('foo'), u'') + + def test__resolve_partial__not_found__missing_tags_strict(self): """ - Check that load_partial provides a nice message when a template is not found. + Check that resolve_partial provides a nice message when a template is not found. """ renderer = Renderer() + renderer.missing_tags = 'strict' engine = renderer._make_render_engine() - load_partial = engine.load_partial + resolve_partial = engine.resolve_partial self.assertException(TemplateNotFoundError, "File 'foo.mustache' not found in dirs: ['.']", - load_partial, "foo") + resolve_partial, "foo") - def test__load_partial__not_found__dict(self): + def test__resolve_partial__not_found__partials_dict(self): """ - Check that load_partial provides a nice message when a template is not found. + Check that resolve_partial returns the empty string when a template is not found. """ renderer = Renderer() renderer.partials = {} engine = renderer._make_render_engine() - load_partial = engine.load_partial + resolve_partial = engine.resolve_partial + + self.assertString(resolve_partial('foo'), u'') + + def test__resolve_partial__not_found__partials_dict__missing_tags_strict(self): + """ + Check that resolve_partial provides a nice message when a template is not found. - # Include dict directly since str(dict) is different in Python 2 and 3: - # <type 'dict'> versus <class 'dict'>, respectively. + """ + renderer = Renderer() + renderer.missing_tags = 'strict' + renderer.partials = {} + + engine = renderer._make_render_engine() + resolve_partial = engine.resolve_partial + + # Include dict directly since str(dict) is different in Python 2 and 3: + # <type 'dict'> versus <class 'dict'>, respectively. self.assertException(TemplateNotFoundError, "Name 'foo' not found in partials: %s" % dict, - load_partial, "foo") + resolve_partial, "foo") ## Test the engine's literal attribute. @@ -595,3 +679,47 @@ class Renderer_MakeRenderEngineTests(unittest.TestCase, AssertExceptionMixin): self.assertTrue(isinstance(s, unicode)) self.assertEqual(type(escape(s)), unicode) + ## Test the missing_tags attribute. + + def test__missing_tags__unknown_value(self): + """ + Check missing_tags attribute: setting an unknown value. + + """ + renderer = Renderer() + renderer.missing_tags = 'foo' + + self.assertException(Exception, "Unsupported 'missing_tags' value: 'foo'", + renderer._make_render_engine) + + ## Test the engine's resolve_context attribute. + + def test__resolve_context(self): + """ + Check resolve_context(): default arguments. + + """ + renderer = Renderer() + + engine = renderer._make_render_engine() + + stack = ContextStack({'foo': 'bar'}) + + self.assertEqual('bar', engine.resolve_context(stack, 'foo')) + self.assertString(u'', engine.resolve_context(stack, 'missing')) + + def test__resolve_context__missing_tags_strict(self): + """ + Check resolve_context(): missing_tags 'strict'. + + """ + renderer = Renderer() + renderer.missing_tags = 'strict' + + engine = renderer._make_render_engine() + + stack = ContextStack({'foo': 'bar'}) + + self.assertEqual('bar', engine.resolve_context(stack, 'foo')) + self.assertException(KeyNotFoundError, "Key 'missing' not found: first part", + engine.resolve_context, stack, 'missing') diff --git a/pystache/tests/test_specloader.py b/pystache/tests/test_specloader.py index 24fb34d..d934987 100644 --- a/pystache/tests/test_specloader.py +++ b/pystache/tests/test_specloader.py @@ -30,6 +30,14 @@ class Thing(object): pass +class AssertPathsMixin: + + """A unittest.TestCase mixin to check path equality.""" + + def assertPaths(self, actual, expected): + self.assertEqual(actual, expected) + + class ViewTestCase(unittest.TestCase, AssertStringMixin): def test_template_rel_directory(self): @@ -174,7 +182,8 @@ def _make_specloader(): return SpecLoader(loader=loader) -class SpecLoaderTests(unittest.TestCase, AssertIsMixin, AssertStringMixin): +class SpecLoaderTests(unittest.TestCase, AssertIsMixin, AssertStringMixin, + AssertPathsMixin): """ Tests template_spec.SpecLoader. @@ -288,13 +297,21 @@ class SpecLoaderTests(unittest.TestCase, AssertIsMixin, AssertStringMixin): self.assertEqual(loader.s, "template-foo") self.assertEqual(loader.encoding, "encoding-foo") + def test_find__template_path(self): + """Test _find() with TemplateSpec.template_path.""" + loader = self._make_specloader() + custom = TemplateSpec() + custom.template_path = "path/foo" + actual = loader._find(custom) + self.assertPaths(actual, "path/foo") + # TODO: migrate these tests into the SpecLoaderTests class. # TODO: rename the get_template() tests to test load(). # TODO: condense, reorganize, and rename the tests so that it is # clear whether we have full test coverage (e.g. organized by # TemplateSpec attributes or something). -class TemplateSpecTests(unittest.TestCase): +class TemplateSpecTests(unittest.TestCase, AssertPathsMixin): def _make_loader(self): return _make_specloader() @@ -358,13 +375,6 @@ class TemplateSpecTests(unittest.TestCase): view.template_extension = 'txt' self._assert_template_location(view, (None, 'sample_view.txt')) - def _assert_paths(self, actual, expected): - """ - Assert that two paths are the same. - - """ - self.assertEqual(actual, expected) - def test_find__with_directory(self): """ Test _find() with a view that has a directory specified. @@ -379,7 +389,7 @@ class TemplateSpecTests(unittest.TestCase): actual = loader._find(view) expected = os.path.join(DATA_DIR, 'foo/bar.txt') - self._assert_paths(actual, expected) + self.assertPaths(actual, expected) def test_find__without_directory(self): """ @@ -394,7 +404,7 @@ class TemplateSpecTests(unittest.TestCase): actual = loader._find(view) expected = os.path.join(DATA_DIR, 'sample_view.mustache') - self._assert_paths(actual, expected) + self.assertPaths(actual, expected) def _assert_get_template(self, custom, expected): loader = self._make_loader() |