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 Any of these constructs can have a hypen at the end (`-}}`, `-%}`, `-#}`), 94 which will collapse the whitespace following the tag. 95 96 Construct a Templite with the template text, then use `render` against a 97 dictionary context to create a finished string:: 98 99 templite = Templite(''' 100 <h1>Hello {{name|upper}}!</h1> 101 {% for topic in topics %} 102 <p>You are interested in {{topic}}.</p> 103 {% endif %} 104 ''', 105 {'upper': str.upper}, 106 ) 107 text = templite.render({ 108 'name': "Ned", 109 'topics': ['Python', 'Geometry', 'Juggling'], 110 }) 111 112 """ 113 def __init__(self, text, *contexts): 114 """Construct a Templite with the given `text`. 115 116 `contexts` are dictionaries of values to use for future renderings. 117 These are good for filters and global values. 118 119 """ 120 self.context = {} 121 for context in contexts: 122 self.context.update(context) 123 124 self.all_vars = set() 125 self.loop_vars = set() 126 127 # We construct a function in source form, then compile it and hold onto 128 # it, and execute it to render the template. 129 code = CodeBuilder() 130 131 code.add_line("def render_function(context, do_dots):") 132 code.indent() 133 vars_code = code.add_section() 134 code.add_line("result = []") 135 code.add_line("append_result = result.append") 136 code.add_line("extend_result = result.extend") 137 if env.PY2: 138 code.add_line("to_str = unicode") 139 else: 140 code.add_line("to_str = str") 141 142 buffered = [] 143 144 def flush_output(): 145 """Force `buffered` to the code builder.""" 146 if len(buffered) == 1: 147 code.add_line("append_result(%s)" % buffered[0]) 148 elif len(buffered) > 1: 149 code.add_line("extend_result([%s])" % ", ".join(buffered)) 150 del buffered[:] 151 152 ops_stack = [] 153 154 # Split the text to form a list of tokens. 155 tokens = re.split(r"(?s)({{.*?}}|{%.*?%}|{#.*?#})", text) 156 157 squash = False 158 159 for token in tokens: 160 if token.startswith('{'): 161 start, end = 2, -2 162 squash = (token[-3] == '-') 163 if squash: 164 end = -3 165 166 if token.startswith('{#'): 167 # Comment: ignore it and move on. 168 continue 169 elif token.startswith('{{'): 170 # An expression to evaluate. 171 expr = self._expr_code(token[start:end].strip()) 172 buffered.append("to_str(%s)" % expr) 173 else: 174 # token.startswith('{%') 175 # Action tag: split into words and parse further. 176 flush_output() 177 178 words = token[start:end].strip().split() 179 if words[0] == 'if': 180 # An if statement: evaluate the expression to determine if. 181 if len(words) != 2: 182 self._syntax_error("Don't understand if", token) 183 ops_stack.append('if') 184 code.add_line("if %s:" % self._expr_code(words[1])) 185 code.indent() 186 elif words[0] == 'for': 187 # A loop: iterate over expression result. 188 if len(words) != 4 or words[2] != 'in': 189 self._syntax_error("Don't understand for", token) 190 ops_stack.append('for') 191 self._variable(words[1], self.loop_vars) 192 code.add_line( 193 "for c_%s in %s:" % ( 194 words[1], 195 self._expr_code(words[3]) 196 ) 197 ) 198 code.indent() 199 elif words[0].startswith('end'): 200 # Endsomething. Pop the ops stack. 201 if len(words) != 1: 202 self._syntax_error("Don't understand end", token) 203 end_what = words[0][3:] 204 if not ops_stack: 205 self._syntax_error("Too many ends", token) 206 start_what = ops_stack.pop() 207 if start_what != end_what: 208 self._syntax_error("Mismatched end tag", end_what) 209 code.dedent() 210 else: 211 self._syntax_error("Don't understand tag", words[0]) 212 else: 213 # Literal content. If it isn't empty, output it. 214 if squash: 215 token = token.lstrip() 216 if token: 217 buffered.append(repr(token)) 218 219 if ops_stack: 220 self._syntax_error("Unmatched action tag", ops_stack[-1]) 221 222 flush_output() 223 224 for var_name in self.all_vars - self.loop_vars: 225 vars_code.add_line("c_%s = context[%r]" % (var_name, var_name)) 226 227 code.add_line('return "".join(result)') 228 code.dedent() 229 self._render_function = code.get_globals()['render_function'] 230 231 def _expr_code(self, expr): 232 """Generate a Python expression for `expr`.""" 233 if "|" in expr: 234 pipes = expr.split("|") 235 code = self._expr_code(pipes[0]) 236 for func in pipes[1:]: 237 self._variable(func, self.all_vars) 238 code = "c_%s(%s)" % (func, code) 239 elif "." in expr: 240 dots = expr.split(".") 241 code = self._expr_code(dots[0]) 242 args = ", ".join(repr(d) for d in dots[1:]) 243 code = "do_dots(%s, %s)" % (code, args) 244 else: 245 self._variable(expr, self.all_vars) 246 code = "c_%s" % expr 247 return code 248 249 def _syntax_error(self, msg, thing): 250 """Raise a syntax error using `msg`, and showing `thing`.""" 251 raise TempliteSyntaxError("%s: %r" % (msg, thing)) 252 253 def _variable(self, name, vars_set): 254 """Track that `name` is used as a variable. 255 256 Adds the name to `vars_set`, a set of variable names. 257 258 Raises an syntax error if `name` is not a valid name. 259 260 """ 261 if not re.match(r"[_a-zA-Z][_a-zA-Z0-9]*$", name): 262 self._syntax_error("Not a valid name", name) 263 vars_set.add(name) 264 265 def render(self, context=None): 266 """Render this template by applying it to `context`. 267 268 `context` is a dictionary of values to use in this rendering. 269 270 """ 271 # Make the complete context we'll use. 272 render_context = dict(self.context) 273 if context: 274 render_context.update(context) 275 return self._render_function(render_context, self._do_dots) 276 277 def _do_dots(self, value, *dots): 278 """Evaluate dotted expressions at run-time.""" 279 for dot in dots: 280 try: 281 value = getattr(value, dot) 282 except AttributeError: 283 try: 284 value = value[dot] 285 except (TypeError, KeyError): 286 raise TempliteValueError( 287 "Couldn't evaluate %r.%s" % (value, dot) 288 ) 289 if callable(value): 290 value = value() 291 return value 292