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