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