templite.py 9.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292
  1. # Licensed under the Apache License: http://www.apache.org/licenses/LICENSE-2.0
  2. # For details: https://bitbucket.org/ned/coveragepy/src/default/NOTICE.txt
  3. """A simple Python template renderer, for a nano-subset of Django syntax.
  4. For a detailed discussion of this code, see this chapter from 500 Lines:
  5. http://aosabook.org/en/500L/a-template-engine.html
  6. """
  7. # Coincidentally named the same as http://code.activestate.com/recipes/496702/
  8. import re
  9. from coverage import env
  10. class TempliteSyntaxError(ValueError):
  11. """Raised when a template has a syntax error."""
  12. pass
  13. class TempliteValueError(ValueError):
  14. """Raised when an expression won't evaluate in a template."""
  15. pass
  16. class CodeBuilder(object):
  17. """Build source code conveniently."""
  18. def __init__(self, indent=0):
  19. self.code = []
  20. self.indent_level = indent
  21. def __str__(self):
  22. return "".join(str(c) for c in self.code)
  23. def add_line(self, line):
  24. """Add a line of source to the code.
  25. Indentation and newline will be added for you, don't provide them.
  26. """
  27. self.code.extend([" " * self.indent_level, line, "\n"])
  28. def add_section(self):
  29. """Add a section, a sub-CodeBuilder."""
  30. section = CodeBuilder(self.indent_level)
  31. self.code.append(section)
  32. return section
  33. INDENT_STEP = 4 # PEP8 says so!
  34. def indent(self):
  35. """Increase the current indent for following lines."""
  36. self.indent_level += self.INDENT_STEP
  37. def dedent(self):
  38. """Decrease the current indent for following lines."""
  39. self.indent_level -= self.INDENT_STEP
  40. def get_globals(self):
  41. """Execute the code, and return a dict of globals it defines."""
  42. # A check that the caller really finished all the blocks they started.
  43. assert self.indent_level == 0
  44. # Get the Python source as a single string.
  45. python_source = str(self)
  46. # Execute the source, defining globals, and return them.
  47. global_namespace = {}
  48. exec(python_source, global_namespace)
  49. return global_namespace
  50. class Templite(object):
  51. """A simple template renderer, for a nano-subset of Django syntax.
  52. Supported constructs are extended variable access::
  53. {{var.modifier.modifier|filter|filter}}
  54. loops::
  55. {% for var in list %}...{% endfor %}
  56. and ifs::
  57. {% if var %}...{% endif %}
  58. Comments are within curly-hash markers::
  59. {# This will be ignored #}
  60. Any of these constructs can have a hypen at the end (`-}}`, `-%}`, `-#}`),
  61. which will collapse the whitespace following the tag.
  62. Construct a Templite with the template text, then use `render` against a
  63. dictionary context to create a finished string::
  64. templite = Templite('''
  65. <h1>Hello {{name|upper}}!</h1>
  66. {% for topic in topics %}
  67. <p>You are interested in {{topic}}.</p>
  68. {% endif %}
  69. ''',
  70. {'upper': str.upper},
  71. )
  72. text = templite.render({
  73. 'name': "Ned",
  74. 'topics': ['Python', 'Geometry', 'Juggling'],
  75. })
  76. """
  77. def __init__(self, text, *contexts):
  78. """Construct a Templite with the given `text`.
  79. `contexts` are dictionaries of values to use for future renderings.
  80. These are good for filters and global values.
  81. """
  82. self.context = {}
  83. for context in contexts:
  84. self.context.update(context)
  85. self.all_vars = set()
  86. self.loop_vars = set()
  87. # We construct a function in source form, then compile it and hold onto
  88. # it, and execute it to render the template.
  89. code = CodeBuilder()
  90. code.add_line("def render_function(context, do_dots):")
  91. code.indent()
  92. vars_code = code.add_section()
  93. code.add_line("result = []")
  94. code.add_line("append_result = result.append")
  95. code.add_line("extend_result = result.extend")
  96. if env.PY2:
  97. code.add_line("to_str = unicode")
  98. else:
  99. code.add_line("to_str = str")
  100. buffered = []
  101. def flush_output():
  102. """Force `buffered` to the code builder."""
  103. if len(buffered) == 1:
  104. code.add_line("append_result(%s)" % buffered[0])
  105. elif len(buffered) > 1:
  106. code.add_line("extend_result([%s])" % ", ".join(buffered))
  107. del buffered[:]
  108. ops_stack = []
  109. # Split the text to form a list of tokens.
  110. tokens = re.split(r"(?s)({{.*?}}|{%.*?%}|{#.*?#})", text)
  111. squash = False
  112. for token in tokens:
  113. if token.startswith('{'):
  114. start, end = 2, -2
  115. squash = (token[-3] == '-')
  116. if squash:
  117. end = -3
  118. if token.startswith('{#'):
  119. # Comment: ignore it and move on.
  120. continue
  121. elif token.startswith('{{'):
  122. # An expression to evaluate.
  123. expr = self._expr_code(token[start:end].strip())
  124. buffered.append("to_str(%s)" % expr)
  125. else:
  126. # token.startswith('{%')
  127. # Action tag: split into words and parse further.
  128. flush_output()
  129. words = token[start:end].strip().split()
  130. if words[0] == 'if':
  131. # An if statement: evaluate the expression to determine if.
  132. if len(words) != 2:
  133. self._syntax_error("Don't understand if", token)
  134. ops_stack.append('if')
  135. code.add_line("if %s:" % self._expr_code(words[1]))
  136. code.indent()
  137. elif words[0] == 'for':
  138. # A loop: iterate over expression result.
  139. if len(words) != 4 or words[2] != 'in':
  140. self._syntax_error("Don't understand for", token)
  141. ops_stack.append('for')
  142. self._variable(words[1], self.loop_vars)
  143. code.add_line(
  144. "for c_%s in %s:" % (
  145. words[1],
  146. self._expr_code(words[3])
  147. )
  148. )
  149. code.indent()
  150. elif words[0].startswith('end'):
  151. # Endsomething. Pop the ops stack.
  152. if len(words) != 1:
  153. self._syntax_error("Don't understand end", token)
  154. end_what = words[0][3:]
  155. if not ops_stack:
  156. self._syntax_error("Too many ends", token)
  157. start_what = ops_stack.pop()
  158. if start_what != end_what:
  159. self._syntax_error("Mismatched end tag", end_what)
  160. code.dedent()
  161. else:
  162. self._syntax_error("Don't understand tag", words[0])
  163. else:
  164. # Literal content. If it isn't empty, output it.
  165. if squash:
  166. token = token.lstrip()
  167. if token:
  168. buffered.append(repr(token))
  169. if ops_stack:
  170. self._syntax_error("Unmatched action tag", ops_stack[-1])
  171. flush_output()
  172. for var_name in self.all_vars - self.loop_vars:
  173. vars_code.add_line("c_%s = context[%r]" % (var_name, var_name))
  174. code.add_line('return "".join(result)')
  175. code.dedent()
  176. self._render_function = code.get_globals()['render_function']
  177. def _expr_code(self, expr):
  178. """Generate a Python expression for `expr`."""
  179. if "|" in expr:
  180. pipes = expr.split("|")
  181. code = self._expr_code(pipes[0])
  182. for func in pipes[1:]:
  183. self._variable(func, self.all_vars)
  184. code = "c_%s(%s)" % (func, code)
  185. elif "." in expr:
  186. dots = expr.split(".")
  187. code = self._expr_code(dots[0])
  188. args = ", ".join(repr(d) for d in dots[1:])
  189. code = "do_dots(%s, %s)" % (code, args)
  190. else:
  191. self._variable(expr, self.all_vars)
  192. code = "c_%s" % expr
  193. return code
  194. def _syntax_error(self, msg, thing):
  195. """Raise a syntax error using `msg`, and showing `thing`."""
  196. raise TempliteSyntaxError("%s: %r" % (msg, thing))
  197. def _variable(self, name, vars_set):
  198. """Track that `name` is used as a variable.
  199. Adds the name to `vars_set`, a set of variable names.
  200. Raises an syntax error if `name` is not a valid name.
  201. """
  202. if not re.match(r"[_a-zA-Z][_a-zA-Z0-9]*$", name):
  203. self._syntax_error("Not a valid name", name)
  204. vars_set.add(name)
  205. def render(self, context=None):
  206. """Render this template by applying it to `context`.
  207. `context` is a dictionary of values to use in this rendering.
  208. """
  209. # Make the complete context we'll use.
  210. render_context = dict(self.context)
  211. if context:
  212. render_context.update(context)
  213. return self._render_function(render_context, self._do_dots)
  214. def _do_dots(self, value, *dots):
  215. """Evaluate dotted expressions at run-time."""
  216. for dot in dots:
  217. try:
  218. value = getattr(value, dot)
  219. except AttributeError:
  220. try:
  221. value = value[dot]
  222. except (TypeError, KeyError):
  223. raise TempliteValueError(
  224. "Couldn't evaluate %r.%s" % (value, dot)
  225. )
  226. if callable(value):
  227. value = value()
  228. return value