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