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 |
|
6 For a detailed discussion of this code, see this chapter from 500 Lines: |
|
7 http://aosabook.org/en/500L/a-template-engine.html |
|
8 |
|
9 """ |
|
10 |
|
11 # Coincidentally named the same as http://code.activestate.com/recipes/496702/ |
|
12 |
|
13 import re |
|
14 |
|
15 from coverage import env |
|
16 |
|
17 |
|
18 class TempliteSyntaxError(ValueError): |
|
19 """Raised when a template has a syntax error.""" |
|
20 pass |
|
21 |
|
22 |
|
23 class TempliteValueError(ValueError): |
|
24 """Raised when an expression won't evaluate in a template.""" |
|
25 pass |
|
26 |
|
27 |
|
28 class 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 |
|
74 class 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 elif token.startswith('{%'): |
|
174 # Action tag: split into words and parse further. |
|
175 flush_output() |
|
176 |
|
177 words = token[start:end].strip().split() |
|
178 if words[0] == 'if': |
|
179 # An if statement: evaluate the expression to determine if. |
|
180 if len(words) != 2: |
|
181 self._syntax_error("Don't understand if", token) |
|
182 ops_stack.append('if') |
|
183 code.add_line("if %s:" % self._expr_code(words[1])) |
|
184 code.indent() |
|
185 elif words[0] == 'for': |
|
186 # A loop: iterate over expression result. |
|
187 if len(words) != 4 or words[2] != 'in': |
|
188 self._syntax_error("Don't understand for", token) |
|
189 ops_stack.append('for') |
|
190 self._variable(words[1], self.loop_vars) |
|
191 code.add_line( |
|
192 "for c_%s in %s:" % ( |
|
193 words[1], |
|
194 self._expr_code(words[3]) |
|
195 ) |
|
196 ) |
|
197 code.indent() |
|
198 elif words[0].startswith('end'): |
|
199 # Endsomething. Pop the ops stack. |
|
200 if len(words) != 1: |
|
201 self._syntax_error("Don't understand end", token) |
|
202 end_what = words[0][3:] |
|
203 if not ops_stack: |
|
204 self._syntax_error("Too many ends", token) |
|
205 start_what = ops_stack.pop() |
|
206 if start_what != end_what: |
|
207 self._syntax_error("Mismatched end tag", end_what) |
|
208 code.dedent() |
|
209 else: |
|
210 self._syntax_error("Don't understand tag", words[0]) |
|
211 else: |
|
212 # Literal content. If it isn't empty, output it. |
|
213 if squash: |
|
214 token = token.lstrip() |
|
215 if token: |
|
216 buffered.append(repr(token)) |
|
217 |
|
218 if ops_stack: |
|
219 self._syntax_error("Unmatched action tag", ops_stack[-1]) |
|
220 |
|
221 flush_output() |
|
222 |
|
223 for var_name in self.all_vars - self.loop_vars: |
|
224 vars_code.add_line("c_%s = context[%r]" % (var_name, var_name)) |
|
225 |
|
226 code.add_line('return "".join(result)') |
|
227 code.dedent() |
|
228 self._render_function = code.get_globals()['render_function'] |
|
229 |
|
230 def _expr_code(self, expr): |
|
231 """Generate a Python expression for `expr`.""" |
|
232 if "|" in expr: |
|
233 pipes = expr.split("|") |
|
234 code = self._expr_code(pipes[0]) |
|
235 for func in pipes[1:]: |
|
236 self._variable(func, self.all_vars) |
|
237 code = "c_%s(%s)" % (func, code) |
|
238 elif "." in expr: |
|
239 dots = expr.split(".") |
|
240 code = self._expr_code(dots[0]) |
|
241 args = ", ".join(repr(d) for d in dots[1:]) |
|
242 code = "do_dots(%s, %s)" % (code, args) |
|
243 else: |
|
244 self._variable(expr, self.all_vars) |
|
245 code = "c_%s" % expr |
|
246 return code |
|
247 |
|
248 def _syntax_error(self, msg, thing): |
|
249 """Raise a syntax error using `msg`, and showing `thing`.""" |
|
250 raise TempliteSyntaxError("%s: %r" % (msg, thing)) |
|
251 |
|
252 def _variable(self, name, vars_set): |
|
253 """Track that `name` is used as a variable. |
|
254 |
|
255 Adds the name to `vars_set`, a set of variable names. |
|
256 |
|
257 Raises an syntax error if `name` is not a valid name. |
|
258 |
|
259 """ |
|
260 if not re.match(r"[_a-zA-Z][_a-zA-Z0-9]*$", name): |
|
261 self._syntax_error("Not a valid name", name) |
|
262 vars_set.add(name) |
|
263 |
|
264 def render(self, context=None): |
|
265 """Render this template by applying it to `context`. |
|
266 |
|
267 `context` is a dictionary of values to use in this rendering. |
|
268 |
|
269 """ |
|
270 # Make the complete context we'll use. |
|
271 render_context = dict(self.context) |
|
272 if context: |
|
273 render_context.update(context) |
|
274 return self._render_function(render_context, self._do_dots) |
|
275 |
|
276 def _do_dots(self, value, *dots): |
|
277 """Evaluate dotted expressions at run-time.""" |
|
278 for dot in dots: |
|
279 try: |
|
280 value = getattr(value, dot) |
|
281 except AttributeError: |
|
282 try: |
|
283 value = value[dot] |
|
284 except (TypeError, KeyError): |
|
285 raise TempliteValueError( |
|
286 "Couldn't evaluate %r.%s" % (value, dot) |
|
287 ) |
|
288 if callable(value): |
|
289 value = value() |
|
290 return value |
|
291 |
|
292 # |
|
293 # eflag: FileType = Python2 |
|